mirror of
https://github.com/junegunn/fzf.git
synced 2026-09-07 15:29:16 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15f64c492a | ||
|
|
bd4efa277b | ||
|
|
63f6cfec5b | ||
|
|
d4b6ba781c | ||
|
|
85a2a33612 | ||
|
|
ca4c1b80e3 | ||
|
|
dab626bd9e | ||
|
|
2885df8395 | ||
|
|
715d26fa39 | ||
|
|
ab1ee05e51 | ||
|
|
7b16e44f53 | ||
|
|
9dfdba41f5 | ||
|
|
465837f3ad | ||
|
|
a650900eda | ||
|
|
793e58b558 | ||
|
|
759b7c3283 | ||
|
|
0579bb0e0d | ||
|
|
3337be9d45 | ||
|
|
e36576429d | ||
|
|
ca51a57aed | ||
|
|
b66153825f | ||
|
|
8cf40cb372 | ||
|
|
57631684d8 | ||
|
|
50e6c026ec | ||
|
|
b5f2ba4fd4 | ||
|
|
d39595ae66 | ||
|
|
548dfcb068 | ||
|
|
4215d89e11 | ||
|
|
62f96109a3 | ||
|
|
ad41cb21ed | ||
|
|
13a83e3475 | ||
|
|
f779e6a4df | ||
|
|
232722145e | ||
|
|
7129f9ef5e | ||
|
|
0efef298d2 | ||
|
|
2209a94e13 | ||
|
|
9c401f9c9a | ||
|
|
224310d7a6 | ||
|
|
235a726fae | ||
|
|
dc384afb70 | ||
|
|
6e65d867dc | ||
|
|
b163463079 | ||
|
|
eae8d9d27e | ||
|
|
e58c5e6bd8 | ||
|
|
7fe4831be6 | ||
|
|
6030226835 | ||
|
|
e877b6f846 | ||
|
|
b9cbe3a6b8 | ||
|
|
7e134f0a78 | ||
|
|
3c0de84dab | ||
|
|
374b01242d | ||
|
|
c0860259fa | ||
|
|
1462f1358d | ||
|
|
8e340dabbc | ||
|
|
4dcedc01df | ||
|
|
24832e97ef | ||
|
|
6765f464a6 | ||
|
|
c110aec3c4 | ||
|
|
e54f11c64e | ||
|
|
f7392a8b63 | ||
|
|
eee92b1b2a | ||
|
|
77e6394f50 | ||
|
|
1e31e5dfbe | ||
|
|
a1fb01462d | ||
|
|
978ded5cd0 | ||
|
|
743930a38d | ||
|
|
8ec443ba40 | ||
|
|
9e2856559d | ||
|
|
9e4f8e7b7a | ||
|
|
f2e451596c | ||
|
|
109057877b | ||
|
|
0fd47770fc | ||
|
|
3c9965a61a | ||
|
|
3951df8537 | ||
|
|
5dd698b869 | ||
|
|
f5fbfd848e | ||
|
|
dea72834ed | ||
|
|
abee152255 | ||
|
|
bf114bcc21 | ||
|
|
838ac7554b | ||
|
|
ae78a5c56d | ||
|
|
7d647c70c2 | ||
|
|
6bd17f8f9a | ||
|
|
249a6df4a4 | ||
|
|
a50619388d | ||
|
|
5ef8dea36e | ||
|
|
845752f305 | ||
|
|
9a61a1457d | ||
|
|
dfcacb443d | ||
|
|
5412f39b84 | ||
|
|
07c5cd4185 | ||
|
|
ce4bef7595 | ||
|
|
25868a62f7 | ||
|
|
7963a2c658 | ||
|
|
4b23aa45a8 | ||
|
|
3953d1c649 | ||
|
|
5e137613d3 | ||
|
|
a24deef77b | ||
|
|
1b6e17ca39 | ||
|
|
94f6daa61c | ||
|
|
02594f8dbc | ||
|
|
f81cb1939c | ||
|
|
290b18d9fe | ||
|
|
ccedd064ca | ||
|
|
d4352a013d | ||
|
|
665bef56ea | ||
|
|
e912cdb3e4 | ||
|
|
de1fca99d5 | ||
|
|
677e854850 | ||
|
|
67319aed0b | ||
|
|
367177d911 | ||
|
|
5819e5ff2f | ||
|
|
fcc3c6acce | ||
|
|
e0d081906f | ||
|
|
263eb4732f | ||
|
|
b4a86a9c8a | ||
|
|
6fefe02546 | ||
|
|
f783582561 | ||
|
|
af42fde089 | ||
|
|
27dab2422e | ||
|
|
56be41218c | ||
|
|
7782da6c00 | ||
|
|
43f0508dd2 | ||
|
|
1986d101e0 | ||
|
|
dd7a081b93 | ||
|
|
d247284bc3 | ||
|
|
6f3e5de150 | ||
|
|
0142fe9d04 | ||
|
|
251b08b831 | ||
|
|
01567b4f06 | ||
|
|
d754cfab87 | ||
|
|
ef6eba1b89 | ||
|
|
faa6a7e9f6 | ||
|
|
8b66489987 | ||
|
|
780a624ed7 | ||
|
|
1ad7c617b1 | ||
|
|
06e70570e2 | ||
|
|
bd4a18d0a9 | ||
|
|
1b0d448c6e | ||
|
|
f584cf38f7 | ||
|
|
0eb2ae9f8b | ||
|
|
1831500018 | ||
|
|
62899fd74e | ||
|
|
d1cea64a0e | ||
|
|
d7a132b8bf | ||
|
|
59b82670f7 | ||
|
|
c516acaa89 | ||
|
|
11ff4745ad | ||
|
|
23164c2263 | ||
|
|
9f8294de62 | ||
|
|
b7138e67a6 | ||
|
|
0f0751b3b6 | ||
|
|
cc16a97a40 | ||
|
|
3ccf162cf0 | ||
|
|
fff7eda9d7 | ||
|
|
d198c0c9de | ||
|
|
55d5b153e6 | ||
|
|
57695b0309 | ||
|
|
6f17d49dbb | ||
|
|
87586ac5eb | ||
|
|
12be5e7b83 | ||
|
|
1acf980e95 | ||
|
|
14e3995a06 | ||
|
|
2202481705 | ||
|
|
6153004070 | ||
|
|
95f186f364 | ||
|
|
58b2855513 | ||
|
|
a00df93e13 | ||
|
|
76efddd718 | ||
|
|
b638ff46fb | ||
|
|
259e841a77 | ||
|
|
f0a2f5ef14 | ||
|
|
2ae7367e8a | ||
|
|
6f33df755e | ||
|
|
2aec7d5201 | ||
|
|
fc60406684 | ||
|
|
cf57950301 | ||
|
|
48c4913392 | ||
|
|
17f2aa1a1f | ||
|
|
b5f7221580 | ||
|
|
e6b9a08699 | ||
|
|
8dbb3b352d | ||
|
|
9f422851fe | ||
|
|
7a811f0cb8 | ||
|
|
b80059e21f | ||
|
|
26de195bbb | ||
|
|
b59f27ef5a | ||
|
|
f3ca0b1365 | ||
|
|
a8e1ef0989 | ||
|
|
2f27a3ede2 | ||
|
|
9249ea1739 | ||
|
|
92bfe68c74 | ||
|
|
92dc40ea82 | ||
|
|
12a280ba14 | ||
|
|
0c6ead6e98 | ||
|
|
280a011f02 | ||
|
|
d324580840 | ||
|
|
f9830c5a3d | ||
|
|
95bc5b8f0c | ||
|
|
0b08f0dea0 | ||
|
|
e7300fe300 | ||
|
|
260d160973 | ||
|
|
d57ed157ad | ||
|
|
9226bc605d | ||
|
|
eacef5ea6e | ||
|
|
96eb68ce63 | ||
|
|
50be8bc78e | ||
|
|
b4e585779a | ||
|
|
97ac7794cf | ||
|
|
4866c34361 | ||
|
|
3cfee281b4 | ||
|
|
5887edc6ba | ||
|
|
3e751c4e87 | ||
|
|
8452c78cc8 | ||
|
|
2db14b4308 | ||
|
|
90c4269d4e | ||
|
|
6087055305 | ||
|
|
2f9df91171 | ||
|
|
12e24d368c | ||
|
|
55193ee4dc | ||
|
|
ff6a3bbee0 | ||
|
|
dce248ac6d | ||
|
|
0ff13dcfbe | ||
|
|
4d6a7757b8 | ||
|
|
b9804f5873 | ||
|
|
98a3b1fff8 | ||
|
|
6df5ca17e8 | ||
|
|
09ca45f7db | ||
|
|
09fe3a4180 |
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
root = true
|
root = true
|
||||||
|
|
||||||
[*.{sh,bash}]
|
[*.{sh,bash,fish}]
|
||||||
indent_style = space
|
indent_style = space
|
||||||
indent_size = 2
|
indent_size = 2
|
||||||
simplify = true
|
simplify = true
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
* @junegunn
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
## Contribution Policy
|
||||||
|
|
||||||
|
We do not accept pull requests generated primarily by AI without genuine understanding or real-world usage context.
|
||||||
|
|
||||||
|
All contributions are expected to demonstrate:
|
||||||
|
- A clear understanding of the codebase
|
||||||
|
- Alignment with product direction
|
||||||
|
- Thoughtful reasoning behind changes
|
||||||
|
- Evidence of real-world usage or hands-on experience with the problem
|
||||||
|
|
||||||
|
If these expectations are not met, we would prefer to implement the changes ourselves rather than spend time reviewing low-effort submissions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acknowledgement
|
||||||
|
|
||||||
|
- [ ] I confirm that this PR meets the above expectations and reflects my own understanding and real-world context.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
go:
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- src/**
|
||||||
|
- main.go
|
||||||
|
- go.mod
|
||||||
|
- go.sum
|
||||||
|
|
||||||
|
shell:
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- shell/**
|
||||||
|
|
||||||
|
bash:
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- shell/**/*.bash
|
||||||
|
|
||||||
|
zsh:
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- shell/**/*.zsh
|
||||||
|
|
||||||
|
fish:
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- shell/**/*.fish
|
||||||
|
|
||||||
|
vim:
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- plugin/**
|
||||||
|
|
||||||
|
docs:
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- '*.md'
|
||||||
|
- doc/**
|
||||||
|
- man/**
|
||||||
|
|
||||||
|
ci:
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- .github/**
|
||||||
|
|
||||||
|
build:
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- Makefile
|
||||||
|
- .goreleaser.yml
|
||||||
|
- Dockerfile
|
||||||
|
|
||||||
|
test:
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- test/**
|
||||||
|
- src/**/*_test.go
|
||||||
|
|
||||||
|
install:
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- install
|
||||||
|
- install.ps1
|
||||||
|
- uninstall
|
||||||
@@ -27,7 +27,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,6 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: 'Checkout Repository'
|
- name: 'Checkout Repository'
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v7
|
||||||
- name: 'Dependency Review'
|
- name: 'Dependency Review'
|
||||||
uses: actions/dependency-review-action@v4
|
uses: actions/dependency-review-action@v5
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
name: Label PRs
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types: [opened, synchronize, reopened]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
label:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/labeler@v7
|
||||||
|
with:
|
||||||
|
configuration-path: .github/labeler.yml
|
||||||
@@ -5,7 +5,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [ master, devel ]
|
branches: [ master, devel ]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ master ]
|
branches: [ master, devel ]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -18,22 +18,27 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version: "1.23"
|
go-version: "1.23"
|
||||||
|
|
||||||
- name: Setup Ruby
|
- name: Setup Ruby
|
||||||
uses: ruby/setup-ruby@v1
|
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
|
||||||
with:
|
with:
|
||||||
ruby-version: 3.4.6
|
ruby-version: 3.4.6
|
||||||
|
|
||||||
- name: Install packages
|
- name: Install packages
|
||||||
run: sudo apt-get install --yes zsh fish tmux shfmt
|
run: |
|
||||||
|
sudo install -d -m 0755 /etc/apt/keyrings
|
||||||
|
wget -qO- https://apt.fury.io/nushell/gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/fury-nushell.gpg
|
||||||
|
echo "deb [signed-by=/etc/apt/keyrings/fury-nushell.gpg] https://apt.fury.io/nushell/ /" | sudo tee /etc/apt/sources.list.d/fury-nushell.list
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install --yes zsh fish tmux shfmt nushell
|
||||||
|
|
||||||
- name: Install Ruby gems
|
- name: Install Ruby gems
|
||||||
run: bundle install
|
run: bundle install
|
||||||
@@ -44,5 +49,10 @@ jobs:
|
|||||||
- name: Unit test
|
- name: Unit test
|
||||||
run: make test
|
run: make test
|
||||||
|
|
||||||
|
- name: Fuzz test
|
||||||
|
run: |
|
||||||
|
go test ./src/algo/ -fuzz=FuzzIndexByteTwo -fuzztime=5s
|
||||||
|
go test ./src/algo/ -fuzz=FuzzLastIndexByteTwo -fuzztime=5s
|
||||||
|
|
||||||
- name: Integration test
|
- name: Integration test
|
||||||
run: make install && ./install --all && tmux new-session -d && ruby test/runner.rb --verbose
|
run: make install && ./install --all && tmux new-session -d && ruby test/runner.rb --verbose
|
||||||
|
|||||||
@@ -15,17 +15,17 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version: "1.23"
|
go-version: "1.23"
|
||||||
|
|
||||||
- name: Setup Ruby
|
- name: Setup Ruby
|
||||||
uses: ruby/setup-ruby@v1
|
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
|
||||||
with:
|
with:
|
||||||
ruby-version: 3.0.0
|
ruby-version: 3.0.0
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: 'Version to validate (e.g. 0.73.0).'
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: macos-latest
|
||||||
|
environment: release
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v7
|
||||||
|
with:
|
||||||
|
go-version: stable
|
||||||
|
|
||||||
|
- name: Determine version
|
||||||
|
id: ver
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" = "push" ]; then
|
||||||
|
v=${GITHUB_REF_NAME#v}
|
||||||
|
else
|
||||||
|
v='${{ inputs.version }}'
|
||||||
|
fi
|
||||||
|
echo "version=$v" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Resolved version: '$v'"
|
||||||
|
|
||||||
|
- name: Verify version consistency
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
V='${{ steps.ver.outputs.version }}'
|
||||||
|
R=$(echo "$V" | sed 's/\./\\./g')
|
||||||
|
grep -q "^${R}$" CHANGELOG.md
|
||||||
|
grep -qF "\"fzf ${V}\"" man/man1/fzf.1
|
||||||
|
grep -qF "\"fzf ${V}\"" man/man1/fzf-tmux.1
|
||||||
|
grep -qF "${V}" install
|
||||||
|
grep -qF "${V}" install.ps1
|
||||||
|
|
||||||
|
- name: Extract release notes
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
mkdir -p tmp
|
||||||
|
V='${{ steps.ver.outputs.version }}'
|
||||||
|
R=$(echo "$V" | sed 's/\./\\./g')
|
||||||
|
sed -n "/^${R}$/,/^[0-9]/p" CHANGELOG.md \
|
||||||
|
| tail -r | sed '1,/^ *$/d' | tail -r | sed '1,2d' \
|
||||||
|
| tee tmp/release-note
|
||||||
|
|
||||||
|
- name: Run goreleaser
|
||||||
|
uses: goreleaser/goreleaser-action@v7
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
args: >-
|
||||||
|
${{ github.event_name == 'push'
|
||||||
|
&& 'release --clean --release-notes tmp/release-note'
|
||||||
|
|| 'release --snapshot --clean --skip=publish' }}
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.RELEASE_PAT }}
|
||||||
|
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
|
||||||
|
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
|
||||||
|
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
|
||||||
|
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
|
||||||
|
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
---
|
|
||||||
name: Generate Sponsors README
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
schedule:
|
|
||||||
- cron: 0 15 * * 6
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout 🛎️
|
|
||||||
uses: actions/checkout@v5
|
|
||||||
|
|
||||||
- name: Generate Sponsors 💖
|
|
||||||
uses: JamesIves/github-sponsors-readme-action@v1
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.SPONSORS_TOKEN }}
|
|
||||||
file: 'README.md'
|
|
||||||
|
|
||||||
- name: Deploy to GitHub Pages 🚀
|
|
||||||
uses: JamesIves/github-pages-deploy-action@v4
|
|
||||||
with:
|
|
||||||
branch: master
|
|
||||||
folder: '.'
|
|
||||||
@@ -6,5 +6,5 @@ jobs:
|
|||||||
name: Spell Check with Typos
|
name: Spell Check with Typos
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v7
|
||||||
- uses: crate-ci/typos@v1.29.4
|
- uses: crate-ci/typos@685eb3d55be2f85191e8c84acb9f44d7756f84ab # v1.29.4
|
||||||
|
|||||||
@@ -2,13 +2,20 @@ name: Publish to Winget
|
|||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
types: [released]
|
types: [released]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
release-tag:
|
||||||
|
description: 'Release tag to submit (e.g. v0.73.1)'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish:
|
publish:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: vedantmgoyal2009/winget-releaser@v2
|
- uses: vedantmgoyal2009/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2
|
||||||
with:
|
with:
|
||||||
identifier: junegunn.fzf
|
identifier: junegunn.fzf
|
||||||
|
release-tag: ${{ inputs.release-tag || github.event.release.tag_name }}
|
||||||
installers-regex: '-windows_(armv7|arm64|amd64)\.zip$'
|
installers-regex: '-windows_(armv7|arm64|amd64)\.zip$'
|
||||||
token: ${{ secrets.WINGET_TOKEN }}
|
token: ${{ secrets.WINGET_TOKEN }}
|
||||||
|
|||||||
@@ -96,6 +96,25 @@ archives:
|
|||||||
files:
|
files:
|
||||||
- non-existent*
|
- non-existent*
|
||||||
|
|
||||||
|
nfpms:
|
||||||
|
- package_name: fzf
|
||||||
|
file_name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
|
||||||
|
vendor: junegunn
|
||||||
|
homepage: https://github.com/junegunn/fzf
|
||||||
|
maintainer: Junegunn Choi <junegunn.c@gmail.com>
|
||||||
|
description: Command-line fuzzy finder
|
||||||
|
license: MIT
|
||||||
|
section: utils
|
||||||
|
formats:
|
||||||
|
- deb
|
||||||
|
ids:
|
||||||
|
- fzf
|
||||||
|
contents:
|
||||||
|
- src: man/man1/fzf.1
|
||||||
|
dst: /usr/share/man/man1/fzf.1
|
||||||
|
- src: LICENSE
|
||||||
|
dst: /usr/share/doc/fzf/copyright
|
||||||
|
|
||||||
release:
|
release:
|
||||||
github:
|
github:
|
||||||
owner: junegunn
|
owner: junegunn
|
||||||
|
|||||||
+7
-7
@@ -309,16 +309,16 @@ I know it's a lot to digest, let's try to break down the code.
|
|||||||
available color options.
|
available color options.
|
||||||
- The value of `--preview-window` option consists of 5 components delimited
|
- The value of `--preview-window` option consists of 5 components delimited
|
||||||
by `,`
|
by `,`
|
||||||
1. `up` — Position of the preview window
|
1. `up` -- Position of the preview window
|
||||||
1. `60%` — Size of the preview window
|
1. `60%` -- Size of the preview window
|
||||||
1. `border-bottom` — Preview window border only on the bottom side
|
1. `border-bottom` -- Preview window border only on the bottom side
|
||||||
1. `+{2}+3/3` — Scroll offset of the preview contents
|
1. `+{2}+3/3` -- Scroll offset of the preview contents
|
||||||
1. `~3` — Fixed header
|
1. `~3` -- Fixed header
|
||||||
- Let's break down the latter two. We want to display the bat output in the
|
- Let's break down the latter two. We want to display the bat output in the
|
||||||
preview window with a certain scroll offset so that the matching line is
|
preview window with a certain scroll offset so that the matching line is
|
||||||
positioned near the center of the preview window.
|
positioned near the center of the preview window.
|
||||||
- `+{2}` — The base offset is extracted from the second token
|
- `+{2}` -- The base offset is extracted from the second token
|
||||||
- `+3` — We add 3 lines to the base offset to compensate for the header
|
- `+3` -- We add 3 lines to the base offset to compensate for the header
|
||||||
part of `bat` output
|
part of `bat` output
|
||||||
- ```
|
- ```
|
||||||
───────┬──────────────────────────────────────────────────────────
|
───────┬──────────────────────────────────────────────────────────
|
||||||
|
|||||||
+253
@@ -1,6 +1,259 @@
|
|||||||
CHANGELOG
|
CHANGELOG
|
||||||
=========
|
=========
|
||||||
|
|
||||||
|
0.74.3
|
||||||
|
------
|
||||||
|
- Performance optimizations for non-ASCII input
|
||||||
|
- ASCII queries are up to 16x faster
|
||||||
|
- Non-ASCII queries are up to 12x faster
|
||||||
|
- Reading accented Latin input is up to 37% faster
|
||||||
|
- Reading CJK input reduces memory use by up to 29%
|
||||||
|
- ASCII input is unaffected
|
||||||
|
- Fixed an image from a preview command being torn apart when its rows are separated by IND instead of newlines, as `chafa` does under tmux (#4885)
|
||||||
|
- Fixed `replace-query` corrupting the item text when the query is edited afterwards
|
||||||
|
- fzf no longer turns bracketed paste mode off on exit when the terminal already had it on, which broke pasting in shells that run fzf from a line editor widget (#4887)
|
||||||
|
- Fixed startup blocking on terminals that never answer escape sequences, such as FreeBSD virtual terminals. fzf waited for a reply until a key was pressed, then dropped that keystroke (#2860, #976)
|
||||||
|
|
||||||
|
0.74.2
|
||||||
|
------
|
||||||
|
- Performance optimizations for short queries
|
||||||
|
- Short queries scan the largest candidate sets, and the first keystroke scans the whole input
|
||||||
|
- Single-character queries are up to 2.4x faster
|
||||||
|
- Two-character queries are up to 1.4x faster
|
||||||
|
- Faster sorting of search results, skipping redundant radix passes
|
||||||
|
- `change-border-label` and `transform-border-label` now work on the native border of a tmux or Zellij floating pane
|
||||||
|
- Fixed Kitty graphics sequences from a preview command being taken by tmux as pane title requests
|
||||||
|
- Fixed an image at the top of the preview being torn by `--preview-window ~N`
|
||||||
|
- Fixed nondeterministic match highlight positions
|
||||||
|
- Fixed signal and resize handlers persisting after `Run()` returns when fzf is used as a library
|
||||||
|
- fzf now detects terminal resize on Windows in `--height` mode (#4790) (@Cyrus580529)
|
||||||
|
- fish: fixed history command being affected by user initialization scripts, and improved timestamp colors in CTRL-R (#4862) (@bitraid)
|
||||||
|
- zsh: fixed CTRL-R not propagating the exit status of fzf when perl is available (#4871) (@LangLangBart, @Toliak)
|
||||||
|
- zsh: fixed `chpwd` hook functions being called twice by ALT-C (#4879) (@LangLangBart, @lucc)
|
||||||
|
|
||||||
|
0.74.1
|
||||||
|
------
|
||||||
|
- The default separator on the info line is no longer shown when the input section is already visually separated from the list section by a border line
|
||||||
|
```sh
|
||||||
|
# No separator shown below the header border
|
||||||
|
fzf --style full --input-border none --header foo
|
||||||
|
|
||||||
|
# Separator shown; no border separates the input section from the list section
|
||||||
|
fzf --style full --input-border none --header foo --no-header-border
|
||||||
|
|
||||||
|
# No separator below the border of the preview window at 'next' position
|
||||||
|
fzf --preview : --preview-window next
|
||||||
|
|
||||||
|
# Conversely, separator is now shown when the input border does not draw
|
||||||
|
# a line facing the list section
|
||||||
|
fzf --input-border bottom
|
||||||
|
```
|
||||||
|
- Rendering improvements
|
||||||
|
- Each frame is now wrapped in synchronized update mode (mode 2026) to reduce flickering on supported terminals
|
||||||
|
- Reduced rendering output by 10-23% by skipping redundant SGR sequences
|
||||||
|
- Fixed ghost characters and misplaced colors inside Zellij by using CHA instead of CR + CUF for horizontal cursor movement (#4858, zellij-org/zellij#5370)
|
||||||
|
- Fixed cursor restoration on exit with `--height --no-clear` inside Neovim terminal by using DECSC/DECRC instead of `CSI s`/`CSI u`
|
||||||
|
- nushell: fixed deprecation error of `str downcase` on nushell 0.114.0 or above (#4857) (@sim590)
|
||||||
|
- Each release now includes `.deb` packages for easy installation on Debian-based distros (#4859)
|
||||||
|
|
||||||
|
0.74.0
|
||||||
|
------
|
||||||
|
_Release highlights: https://junegunn.github.io/fzf/releases/0.74.0/_
|
||||||
|
|
||||||
|
- On tmux 3.7 or above, `--popup` starts fzf in a floating pane instead of a popup (#4850)
|
||||||
|
- Unlike a popup, a floating pane is not modal; you can switch to other panes and windows while fzf is running, move and resize the pane with the mouse, zoom it to fullscreen, and use copy-mode in it
|
||||||
|
- A floating pane always has a native border, which is what makes the pane movable and resizable, so `border-native` is implied
|
||||||
|
- A popup is used instead when a border style is explicitly specified with `--border`, so that the fzf-drawn border is the only border shown (`none` and `line` are treated as no border)
|
||||||
|
```sh
|
||||||
|
fzf --popup --border
|
||||||
|
```
|
||||||
|
- `--border-label` is set as the title of the floating pane, and is displayed on the border if `pane-border-status` is enabled in tmux
|
||||||
|
```sh
|
||||||
|
fzf --popup --border-label ' fzf '
|
||||||
|
```
|
||||||
|
- On Zellij, `--popup` uses the native border by default, consistent with tmux, so that the pane can be moved and resized with the mouse; fzf draws its own border when a border style is explicitly specified with `--border`
|
||||||
|
- `--border-label` is set as the name of the pane, displayed on the native border
|
||||||
|
- Added `result-final` event, a variant of `result` that is not triggered while the input stream is still open (#4835)
|
||||||
|
- Use it for one-shot, per-query actions that would otherwise re-fire on every intermediate snapshot during loading
|
||||||
|
```sh
|
||||||
|
# 'result' fires per intermediate snapshot (header keeps updating during load);
|
||||||
|
# 'result-final' fires once after the stream closes (footer shows the final count)
|
||||||
|
(seq 100; sleep 1; seq 100) | fzf --query 1 \
|
||||||
|
--bind 'result:transform-header(echo result: $FZF_MATCH_COUNT),result-final:transform-footer(echo final: $FZF_MATCH_COUNT)'
|
||||||
|
```
|
||||||
|
- Added `wait` action to block subsequent actions until search completes (#4825)
|
||||||
|
- Useful for chaining query-changing actions with motion actions to ensure operations on complete results
|
||||||
|
```sh
|
||||||
|
# Wait for search to complete before moving to the best match
|
||||||
|
fzf --bind 'start:change-query(foo)+wait+best'
|
||||||
|
```
|
||||||
|
- The initial loading of the input is also considered a search in progress, so `start:wait` can be used to wait until the input is fully loaded
|
||||||
|
```sh
|
||||||
|
# Move to the last item after the input is fully loaded
|
||||||
|
(seq 1000; sleep 1; seq 1001 2000) | fzf --bind 'start:wait+last'
|
||||||
|
```
|
||||||
|
- Bound `alt-left` to `backward-word` and `alt-right` to `forward-word` by default (#4833)
|
||||||
|
- Bug fixes and improvements
|
||||||
|
- Skip `$FZF_CURRENT_ITEM` export when the item is larger than 64 KB; a huge item can overflow `ARG_MAX` and break preview and other child commands with `E2BIG` (#4806)
|
||||||
|
- `transform` and `bg-transform` now allow a bare `put` action in the output to insert the key that triggered the action
|
||||||
|
```sh
|
||||||
|
# Insert the typed key ('a') into the query
|
||||||
|
fzf --bind 'a:transform:echo put'
|
||||||
|
```
|
||||||
|
- `ALT-C` in zsh no longer resolves symbolic links when changing the directory, consistent with the `cd` builtin (#4816) (@silverneko)
|
||||||
|
- Fixed horizontal mouse wheel events being treated as vertical scrolling (#4848) (@jason5122)
|
||||||
|
- Fixed `bw` theme not inheriting overridden colors
|
||||||
|
- fish: `CTRL-R` now works when `$fish_color_normal` or `$fish_color_comment` is empty or invalid (#4831) (@bitraid)
|
||||||
|
- Fixed empty-shell detection in the install script (#4813)
|
||||||
|
- Fixed the install script writing nushell source lines into the config files of other shells (#4812)
|
||||||
|
|
||||||
|
0.73.1
|
||||||
|
------
|
||||||
|
- Bug fixes
|
||||||
|
- Skip `$FZF_CURRENT_ITEM` export when the item contains a NUL byte; `exec(2)` rejects the env, breaking preview and other child commands (#4806)
|
||||||
|
- Fixed O(n^2) HTTP body accumulation in `--listen`; a single ~390 KB request could block the single-threaded server for ~8 s (Michal Majchrowicz, Marcin Wyczechowski, AFINE Team)
|
||||||
|
|
||||||
|
0.73.0
|
||||||
|
------
|
||||||
|
_Release highlights: https://junegunn.github.io/fzf/releases/0.73.0/_
|
||||||
|
|
||||||
|
- Nushell integration via `fzf --nushell` and the installer (#4630) (@sim590)
|
||||||
|
- New `--preview-window=next` position that places the preview adjacent to the input section, on the list side: above the input in the default layout, below it in `--layout=reverse` (#4798)
|
||||||
|
- Timer-driven `every(N)` event for `--bind`, where `N` is seconds
|
||||||
|
- Added `$FZF_IDLE_TIME` (whole seconds) and `$FZF_IDLE_TIME_MS` (milliseconds), holding the elapsed time since the last user activity
|
||||||
|
- Pair with `every(N)` to build idle-based behavior such as auto-accept or auto-quit (#1211)
|
||||||
|
```sh
|
||||||
|
# Live process list; --track --id-nth 2 keeps the cursor on the same PID across reloads
|
||||||
|
fzf --header-lines 1 --track --id-nth 2 --bind 'start,every(2):reload-sync:ps -ef'
|
||||||
|
|
||||||
|
# Auto-accept after 10 seconds of inactivity, with a countdown in the footer after 5s
|
||||||
|
fzf --bind 'every(1):bg-transform:
|
||||||
|
if [[ $FZF_IDLE_TIME -lt 5 ]]; then echo change-footer:
|
||||||
|
elif [[ $FZF_IDLE_TIME -lt 10 ]]; then echo "change-footer:auto-accept in $((10 - FZF_IDLE_TIME))s"
|
||||||
|
else echo accept
|
||||||
|
fi'
|
||||||
|
```
|
||||||
|
- Added `$FZF_CURRENT_ITEM` for shells where quoting `{}` is awkward (#4802)
|
||||||
|
- Bug fixes
|
||||||
|
- Scoring: non-word characters at the start of input or after a delimiter now receive the same boundary bonus as word characters (#4795)
|
||||||
|
- `change-preview-window` no longer resets `wrap` / `wrap-word` state set via `toggle-preview-wrap` / `toggle-preview-wrap-word` (#4791)
|
||||||
|
- Stripped UTF-8-encoded C1 control characters from rendered items to prevent terminal control-sequence injection
|
||||||
|
- Fixed integer-overflow panic in `FuzzyMatchV2` on 32-bit builds (Michal Majchrowicz, Marcin Wyczechowski, AFINE Team)
|
||||||
|
- Fixed `bg-transform` `reload` / `exclude` payloads being dropped
|
||||||
|
- Fixed rendering glitch with preview window on the left combined with footer
|
||||||
|
|
||||||
|
0.72.0
|
||||||
|
------
|
||||||
|
_Release highlights: https://junegunn.github.io/fzf/releases/0.72.0/_
|
||||||
|
|
||||||
|
- `--header-border`, `--header-lines-border`, and `--footer-border` now accept a new `inline` style that embeds the section inside the list frame, separated from the list content by a horizontal line. When the list border has side segments, the separator joins them as T-junctions.
|
||||||
|
- Requires a `--list-border` shape that has both top and bottom segments (`rounded`, `sharp`, `bold`, `double`, `block`, `thinblock`, or `horizontal`); falls back to `line` otherwise. `horizontal` has no side borders, so the separator is drawn without T-junction endpoints.
|
||||||
|
- Sections stack. Example combining all three:
|
||||||
|
```sh
|
||||||
|
ps -ef | fzf --reverse --style full \
|
||||||
|
--header 'Select a process' --header-lines 1 \
|
||||||
|
--bind 'load:transform-footer:echo $FZF_TOTAL_COUNT processes' \
|
||||||
|
--header-border dashed --header-first \
|
||||||
|
--header-lines-border inline --footer-border inline
|
||||||
|
```
|
||||||
|
- `--header-label` and `--footer-label` render on their respective separator row.
|
||||||
|
- The separator inherits `--color list-border` when the section's own border color is not explicitly set.
|
||||||
|
- `inline` takes precedence over `--header-first`: the inline section stays inside the list frame. `--header-border=inline` requires `--header-lines-border` to be `inline` or unset.
|
||||||
|
- New `dashed` border style with dashed edges (`╶` / `┆`) and rounded corners.
|
||||||
|
- `--border=dashed`, `--list-border=dashed`, etc.
|
||||||
|
- Works with inline sections (T-junctions render correctly).
|
||||||
|
- [vim] Move and resize popup window when detecting `VimResized` event (#4778) (@Vulcalien)
|
||||||
|
- Bug fixes
|
||||||
|
- Fixed gutter display in `--style=minimal`
|
||||||
|
- Fixed arrow keys / Home / End without modifiers being ignored under the kitty keyboard protocol (#4776) (@TymekDev)
|
||||||
|
- bash: Persist history deletion when `histappend` is on (#4764)
|
||||||
|
|
||||||
|
0.71.0
|
||||||
|
------
|
||||||
|
_Release highlights: https://junegunn.github.io/fzf/releases/0.71.0/_
|
||||||
|
|
||||||
|
- Added `--popup` as a new name for `--tmux` with Zellij support
|
||||||
|
- `--popup` starts fzf in a tmux popup or a Zellij floating pane
|
||||||
|
- `--tmux` is now an alias for `--popup`
|
||||||
|
- Requires tmux 3.3+ or Zellij 0.44+
|
||||||
|
- Cross-reload item identity with `--id-nth`
|
||||||
|
- Added `--id-nth=NTH` to define item identity fields for cross-reload operations
|
||||||
|
- When a `reload` is triggered with tracking enabled, fzf searches for the tracked item by its identity fields in the new list.
|
||||||
|
- `--track --id-nth ..` tracks by the entire line
|
||||||
|
- `--track --id-nth 1` tracks by the first field
|
||||||
|
- `--track` without `--id-nth` retains the existing index-based tracking behavior
|
||||||
|
- The UI is temporarily blocked (prompt dimmed, input disabled) until the item is found or loading completes.
|
||||||
|
- Press `Escape` or `Ctrl-C` to cancel the blocked state without quitting
|
||||||
|
- Info line shows `+T*` / `+t*` while searching
|
||||||
|
- With `--multi`, selected items are preserved across `reload-sync` by matching their identity fields
|
||||||
|
- Performance improvements
|
||||||
|
- The search performance now scales linearly with the number of CPU cores, as we dropped static partitioning to allow better load balancing across threads.
|
||||||
|
```
|
||||||
|
=== query: 'linux' ===
|
||||||
|
[all] baseline: 21.95ms current: 17.47ms (1.26x) matches: 179966 (12.79%)
|
||||||
|
[1T] baseline: 179.63ms current: 180.53ms (1.00x) matches: 179966 (12.79%)
|
||||||
|
[2T] baseline: 97.38ms current: 90.05ms (1.08x) matches: 179966 (12.79%)
|
||||||
|
[4T] baseline: 53.83ms current: 44.77ms (1.20x) matches: 179966 (12.79%)
|
||||||
|
[8T] baseline: 41.66ms current: 22.58ms (1.84x) matches: 179966 (12.79%)
|
||||||
|
```
|
||||||
|
- Improved the cache structure, reducing memory footprint per entry by 86x.
|
||||||
|
- With the reduced per-entry cost, the cache now has broader coverage.
|
||||||
|
- Shell integration improvements
|
||||||
|
- bash: CTRL-R now supports multi-select and `shift-delete` to delete history entries (#4715)
|
||||||
|
- fish:
|
||||||
|
- Improved command history (CTRL-R) (#4703) (@bitraid)
|
||||||
|
- Rewrite completion script (SHIFT-TAB) (#4731) (@bitraid)
|
||||||
|
- Increase minimum fish version requirement to 3.4.0 (#4731) (@bitraid)
|
||||||
|
- `GET /` HTTP endpoint now includes `positions` field in each match entry, providing the indices of matched characters for external highlighting (#4726)
|
||||||
|
- Allow adaptive height with negative value (`--height=~-HEIGHT`) (#4682)
|
||||||
|
- Bug fixes
|
||||||
|
- `--walker=follow` no longer follows symlinks whose target is an ancestor of the walker root, avoiding severe resource exhaustion when a symlink points outside the tree (e.g. Wine's `z:` → `/`) (#4710)
|
||||||
|
- Fixed AWK tokenizer not treating a new line character as whitespace
|
||||||
|
- Fixed `--{accept,with}-nth` removing trailing whitespaces with a non-default `--delimiter`
|
||||||
|
- Fixed OSC8 hyperlinks being mangled when the URL contains unicode characters (#4707)
|
||||||
|
- Fixed `--with-shell` not handling quoted arguments correctly (#4709)
|
||||||
|
- Fixed child processes not being terminated on Windows (#4723) (@pjeby)
|
||||||
|
- Fixed preview scrollbar not rendered after `toggle-preview`
|
||||||
|
- Fixed preview follow/scroll with long wrapped lines
|
||||||
|
- Fixed tab width when `--frozen-left` is used
|
||||||
|
- Fixed preview mouse events being processed when no preview window exists
|
||||||
|
- zsh: Fixed history widget when `sh_glob` option is on (#4714) (@EvanHahn)
|
||||||
|
|
||||||
|
0.70.0
|
||||||
|
------
|
||||||
|
- Added `change-with-nth` action for dynamically changing the `--with-nth` option.
|
||||||
|
- Requires `--with-nth` to be set initially.
|
||||||
|
- Multiple options separated by `|` can be given to cycle through.
|
||||||
|
```sh
|
||||||
|
echo -e "a b c\nd e f\ng h i" | fzf --with-nth .. \
|
||||||
|
--bind 'space:change-with-nth(1|2|3|1,3|2,3|)'
|
||||||
|
```
|
||||||
|
- Added `change-header-lines` action for dynamically changing the `--header-lines` option
|
||||||
|
- Performance improvements (1.3x to 1.9x faster filtering depending on query)
|
||||||
|
```
|
||||||
|
=== query: 'l' ===
|
||||||
|
[all] baseline: 168.87ms current: 95.21ms (1.77x) matches: 5069891 (94.78%)
|
||||||
|
[1T] baseline: 1652.22ms current: 841.40ms (1.96x) matches: 5069891 (94.78%)
|
||||||
|
|
||||||
|
=== query: 'lin' ===
|
||||||
|
[all] baseline: 343.27ms current: 252.59ms (1.36x) matches: 3516507 (65.74%)
|
||||||
|
[1T] baseline: 3199.89ms current: 2230.64ms (1.43x) matches: 3516507 (65.74%)
|
||||||
|
|
||||||
|
=== query: 'linux' ===
|
||||||
|
[all] baseline: 85.47ms current: 63.72ms (1.34x) matches: 307229 (5.74%)
|
||||||
|
[1T] baseline: 774.64ms current: 589.32ms (1.31x) matches: 307229 (5.74%)
|
||||||
|
|
||||||
|
=== query: 'linuxlinux' ===
|
||||||
|
[all] baseline: 55.13ms current: 35.67ms (1.55x) matches: 12230 (0.23%)
|
||||||
|
[1T] baseline: 461.99ms current: 332.38ms (1.39x) matches: 12230 (0.23%)
|
||||||
|
|
||||||
|
=== query: 'linuxlinuxlinux' ===
|
||||||
|
[all] baseline: 51.77ms current: 32.53ms (1.59x) matches: 865 (0.02%)
|
||||||
|
[1T] baseline: 409.99ms current: 296.33ms (1.38x) matches: 865 (0.02%)
|
||||||
|
```
|
||||||
|
- Fixed `nth` attribute merge order to respect precedence hierarchy (#4697)
|
||||||
|
- bash: Replaced `printf` with builtin `printf` to bypass local indirections (#4684) (@DarrenBishop)
|
||||||
|
|
||||||
0.68.0
|
0.68.0
|
||||||
------
|
------
|
||||||
- Implemented word wrapping in the list section
|
- Implemented word wrapping in the list section
|
||||||
|
|||||||
+11
-1
@@ -1,5 +1,15 @@
|
|||||||
FROM rubylang/ruby:3.4.1-noble
|
FROM rubylang/ruby:3.4.1-noble
|
||||||
RUN apt-get update -y && apt install -y git make golang zsh fish tmux
|
RUN apt-get update && apt-get install -y git make golang zsh fish tmux
|
||||||
|
|
||||||
|
# https://www.nushell.sh/book/installation.html
|
||||||
|
RUN <<EOF
|
||||||
|
set -ex
|
||||||
|
apt-get install -y wget gnupg
|
||||||
|
wget -qO- https://apt.fury.io/nushell/gpg.key | gpg --dearmor -o /etc/apt/keyrings/fury-nushell.gpg
|
||||||
|
echo "deb [signed-by=/etc/apt/keyrings/fury-nushell.gpg] https://apt.fury.io/nushell/ /" | tee /etc/apt/sources.list.d/fury-nushell.list
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y nushell
|
||||||
|
EOF
|
||||||
RUN gem install --no-document -v 5.22.3 minitest
|
RUN gem install --no-document -v 5.22.3 minitest
|
||||||
RUN echo '. /usr/share/bash-completion/completions/git' >> ~/.bashrc
|
RUN echo '. /usr/share/bash-completion/completions/git' >> ~/.bashrc
|
||||||
RUN echo '. ~/.bashrc' >> ~/.bash_profile
|
RUN echo '. ~/.bashrc' >> ~/.bash_profile
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ GEM
|
|||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
ast (2.4.2)
|
ast (2.4.2)
|
||||||
json (2.9.1)
|
json (2.19.9)
|
||||||
language_server-protocol (3.17.0.3)
|
language_server-protocol (3.17.0.3)
|
||||||
minitest (5.25.4)
|
minitest (5.25.4)
|
||||||
parallel (1.26.3)
|
parallel (1.26.3)
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ ifeq ($(UNAME_M),x86_64)
|
|||||||
BINARY := $(BINARY64)
|
BINARY := $(BINARY64)
|
||||||
else ifeq ($(UNAME_M),amd64)
|
else ifeq ($(UNAME_M),amd64)
|
||||||
BINARY := $(BINARY64)
|
BINARY := $(BINARY64)
|
||||||
|
else ifeq ($(UNAME_M),i86pc)
|
||||||
|
BINARY := $(BINARY64)
|
||||||
else ifeq ($(UNAME_M),s390x)
|
else ifeq ($(UNAME_M),s390x)
|
||||||
BINARY := $(BINARYS390)
|
BINARY := $(BINARYS390)
|
||||||
else ifeq ($(UNAME_M),i686)
|
else ifeq ($(UNAME_M),i686)
|
||||||
@@ -95,6 +97,16 @@ test: $(SOURCES)
|
|||||||
itest:
|
itest:
|
||||||
ruby test/runner.rb
|
ruby test/runner.rb
|
||||||
|
|
||||||
|
# Actively fuzz the matcher fast paths against the general algorithm.
|
||||||
|
# Go fuzzes one target at a time, so iterate. Override duration with
|
||||||
|
# FUZZTIME (e.g. make fuzz FUZZTIME=5m).
|
||||||
|
FUZZTIME ?= 30s
|
||||||
|
fuzz:
|
||||||
|
@for t in FuzzFuzzyMatchV2Single FuzzFuzzyMatchV2Two FuzzRunePrefilter; do \
|
||||||
|
echo "== $$t =="; \
|
||||||
|
$(GO) test -run '^$$' -fuzz "^$$t$$" -fuzztime $(FUZZTIME) ./src/algo || exit 1; \
|
||||||
|
done
|
||||||
|
|
||||||
bench:
|
bench:
|
||||||
cd src && SHELL=/bin/sh GOOS= $(GO) test -v -tags "$(TAGS)" -run=Bench -bench=. -benchmem
|
cd src && SHELL=/bin/sh GOOS= $(GO) test -v -tags "$(TAGS)" -run=Bench -bench=. -benchmem
|
||||||
|
|
||||||
@@ -115,6 +127,19 @@ generate:
|
|||||||
build:
|
build:
|
||||||
goreleaser build --clean --snapshot --skip=post-hooks
|
goreleaser build --clean --snapshot --skip=post-hooks
|
||||||
|
|
||||||
|
prerelease:
|
||||||
|
# Check if version numbers are properly updated
|
||||||
|
grep -q ^$(VERSION_REGEX)$$ CHANGELOG.md
|
||||||
|
grep -qF '"fzf $(VERSION_TRIM)"' man/man1/fzf.1
|
||||||
|
grep -qF '"fzf $(VERSION_TRIM)"' man/man1/fzf-tmux.1
|
||||||
|
grep -qF $(VERSION) install
|
||||||
|
grep -qF $(VERSION) install.ps1
|
||||||
|
@echo "OK: all files consistent at $(VERSION)"
|
||||||
|
|
||||||
|
tag: prerelease
|
||||||
|
git tag -s v$(VERSION) -m v$(VERSION)
|
||||||
|
git push origin v$(VERSION)
|
||||||
|
|
||||||
release:
|
release:
|
||||||
# Make sure that the tests pass and the build works
|
# Make sure that the tests pass and the build works
|
||||||
TAGS=tcell make test
|
TAGS=tcell make test
|
||||||
@@ -204,4 +229,4 @@ update:
|
|||||||
$(GO) get -u
|
$(GO) get -u
|
||||||
$(GO) mod tidy
|
$(GO) mod tidy
|
||||||
|
|
||||||
.PHONY: all generate build release test itest bench lint install clean docker docker-test update fmt
|
.PHONY: all generate build prerelease tag release test itest bench lint install clean docker docker-test update fmt
|
||||||
|
|||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
Release process
|
||||||
|
===============
|
||||||
|
|
||||||
|
Building, signing, notarizing, and publishing is handled by
|
||||||
|
[`.github/workflows/release.yml`](.github/workflows/release.yml),
|
||||||
|
triggered by a tag push.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Update version in the following files and commit on `master`:
|
||||||
|
- `CHANGELOG.md`
|
||||||
|
- `main.go`
|
||||||
|
- `install`
|
||||||
|
- `install.ps1`
|
||||||
|
- `man/man1/fzf.1`
|
||||||
|
- `man/man1/fzf-tmux.1`
|
||||||
|
|
||||||
|
2. Verify file consistency, sign the tag, and push the tag.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make tag VERSION=0.74.3
|
||||||
|
```
|
||||||
|
|
||||||
|
`make tag` runs `prerelease` first (checks that the version
|
||||||
|
appears in CHANGELOG.md, both man pages, install, and install.ps1)
|
||||||
|
and only signs + pushes the tag if the checks pass.
|
||||||
|
|
||||||
|
Only the tag is pushed; `master` on origin still points to the
|
||||||
|
old version, so `/master/install` keeps resolving against existing
|
||||||
|
binaries during the publish window.
|
||||||
|
|
||||||
|
3. The workflow fires on the tag push and pauses on the `release`
|
||||||
|
environment gate. Approve it in the Actions tab to release.
|
||||||
|
|
||||||
|
4. After the GitHub release is published, fast-forward `master`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git push origin master
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing the workflow
|
||||||
|
|
||||||
|
To exercise the workflow without firing a real release:
|
||||||
|
|
||||||
|
1. Actions tab -> **Release** -> **Run workflow**.
|
||||||
|
2. Pick a branch and enter the version currently on that branch
|
||||||
|
(the version-consistency check requires the input to match the
|
||||||
|
files in the checked-out tree).
|
||||||
|
3. Approve the `release` environment gate when prompted.
|
||||||
|
4. Goreleaser runs with `--snapshot --skip=publish`. Signing and
|
||||||
|
notarization run; only the GitHub release upload is skipped.
|
||||||
|
|
||||||
|
Use this to validate the workflow YAML, version-extraction logic,
|
||||||
|
the macOS runner setup, and the signing/notarization credentials.
|
||||||
+2
-4
@@ -112,7 +112,7 @@ the whole if we start off with `:FZF` command.
|
|||||||
" Bang version starts fzf in fullscreen mode
|
" Bang version starts fzf in fullscreen mode
|
||||||
:FZF!
|
:FZF!
|
||||||
<
|
<
|
||||||
Similarly to {ctrlp.vim}{3}, use enter key, CTRL-T, CTRL-X or CTRL-V to open
|
Similarly to {ctrlp.vim}{3}, use Enter key, CTRL-T, CTRL-X or CTRL-V to open
|
||||||
selected files in the current window, in new tabs, in horizontal splits, or in
|
selected files in the current window, in new tabs, in horizontal splits, or in
|
||||||
vertical splits respectively.
|
vertical splits respectively.
|
||||||
|
|
||||||
@@ -218,7 +218,6 @@ list:
|
|||||||
`fg` / `bg` / `hl` | Item (foreground / background / highlight)
|
`fg` / `bg` / `hl` | Item (foreground / background / highlight)
|
||||||
`fg+` / `bg+` / `hl+` | Current item (foreground / background / highlight)
|
`fg+` / `bg+` / `hl+` | Current item (foreground / background / highlight)
|
||||||
`preview-fg` / `preview-bg` | Preview window text and background
|
`preview-fg` / `preview-bg` | Preview window text and background
|
||||||
`hl` / `hl+` | Highlighted substrings (normal / current)
|
|
||||||
`gutter` | Background of the gutter on the left
|
`gutter` | Background of the gutter on the left
|
||||||
`pointer` | Pointer to the current line ( `>` )
|
`pointer` | Pointer to the current line ( `>` )
|
||||||
`marker` | Multi-select marker ( `>` )
|
`marker` | Multi-select marker ( `>` )
|
||||||
@@ -229,7 +228,6 @@ list:
|
|||||||
`query` | Query string
|
`query` | Query string
|
||||||
`disabled` | Query string when search is disabled
|
`disabled` | Query string when search is disabled
|
||||||
`prompt` | Prompt before query ( `> ` )
|
`prompt` | Prompt before query ( `> ` )
|
||||||
`pointer` | Pointer to the current line ( `>` )
|
|
||||||
----------------------------+------------------------------------------------------
|
----------------------------+------------------------------------------------------
|
||||||
- `component` specifies the component (`fg` / `bg`) from which to extract the
|
- `component` specifies the component (`fg` / `bg`) from which to extract the
|
||||||
color when considering each of the following highlight groups
|
color when considering each of the following highlight groups
|
||||||
@@ -245,7 +243,7 @@ if it exists, - otherwise use the `fg` attribute of the `Comment` highlight
|
|||||||
group if it exists, - otherwise fall back to the default color settings for
|
group if it exists, - otherwise fall back to the default color settings for
|
||||||
the prompt.
|
the prompt.
|
||||||
|
|
||||||
You can examine the color option generated according the setting by printing
|
You can examine the color option generated according to the setting by printing
|
||||||
the result of `fzf#wrap()` function like so:
|
the result of `fzf#wrap()` function like so:
|
||||||
>
|
>
|
||||||
:echo fzf#wrap()
|
:echo fzf#wrap()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ require (
|
|||||||
github.com/charlievieth/fastwalk v1.0.14
|
github.com/charlievieth/fastwalk v1.0.14
|
||||||
github.com/gdamore/tcell/v2 v2.9.0
|
github.com/gdamore/tcell/v2 v2.9.0
|
||||||
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741
|
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741
|
||||||
github.com/mattn/go-isatty v0.0.20
|
github.com/mattn/go-isatty v0.0.24
|
||||||
github.com/rivo/uniseg v0.4.7
|
github.com/rivo/uniseg v0.4.7
|
||||||
golang.org/x/sys v0.35.0
|
golang.org/x/sys v0.35.0
|
||||||
golang.org/x/term v0.34.0
|
golang.org/x/term v0.34.0
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 h1:7dYDtfMD
|
|||||||
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741/go.mod h1:6EILKtGpo5t+KLb85LNZLAF6P9LKp78hJI80PXMcn3c=
|
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741/go.mod h1:6EILKtGpo5t+KLb85LNZLAF6P9LKp78hJI80PXMcn3c=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
@@ -33,7 +33,6 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
set -u
|
set -u
|
||||||
|
|
||||||
version=0.68.0
|
version=0.74.3
|
||||||
auto_completion=
|
auto_completion=
|
||||||
key_bindings=
|
key_bindings=
|
||||||
update_config=2
|
update_config=2
|
||||||
shells="bash zsh fish"
|
shells="bash zsh fish nushell"
|
||||||
prefix='~/.fzf'
|
prefix='~/.fzf'
|
||||||
prefix_expand=~/.fzf
|
prefix_expand=~/.fzf
|
||||||
fish_dir=${XDG_CONFIG_HOME:-$HOME/.config}/fish
|
fish_dir=${XDG_CONFIG_HOME:-$HOME/.config}/fish
|
||||||
@@ -27,6 +27,7 @@ usage: $0 [OPTIONS]
|
|||||||
--no-bash Do not set up bash configuration
|
--no-bash Do not set up bash configuration
|
||||||
--no-zsh Do not set up zsh configuration
|
--no-zsh Do not set up zsh configuration
|
||||||
--no-fish Do not set up fish configuration
|
--no-fish Do not set up fish configuration
|
||||||
|
--no-nushell Do not set up nushell configuration
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ for opt in "$@"; do
|
|||||||
--no-bash) shells=${shells/bash/} ;;
|
--no-bash) shells=${shells/bash/} ;;
|
||||||
--no-zsh) shells=${shells/zsh/} ;;
|
--no-zsh) shells=${shells/zsh/} ;;
|
||||||
--no-fish) shells=${shells/fish/} ;;
|
--no-fish) shells=${shells/fish/} ;;
|
||||||
|
--no-nushell) shells=${shells/nushell/} ;;
|
||||||
*)
|
*)
|
||||||
echo "unknown option: $opt"
|
echo "unknown option: $opt"
|
||||||
help
|
help
|
||||||
@@ -112,10 +114,15 @@ link_fzf_in_path() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tar_opts="-xzf -"
|
||||||
|
if tar --no-same-owner -tf /dev/null 2> /dev/null; then
|
||||||
|
tar_opts="--no-same-owner $tar_opts"
|
||||||
|
fi
|
||||||
|
|
||||||
try_curl() {
|
try_curl() {
|
||||||
command -v curl > /dev/null &&
|
command -v curl > /dev/null &&
|
||||||
if [[ $1 =~ tar.gz$ ]]; then
|
if [[ $1 =~ tar.gz$ ]]; then
|
||||||
curl -fL $1 | tar --no-same-owner -xzf -
|
curl -fL $1 | tar $tar_opts
|
||||||
else
|
else
|
||||||
local temp=${TMPDIR:-/tmp}/fzf.zip
|
local temp=${TMPDIR:-/tmp}/fzf.zip
|
||||||
curl -fLo "$temp" $1 && unzip -o "$temp" && rm -f "$temp"
|
curl -fLo "$temp" $1 && unzip -o "$temp" && rm -f "$temp"
|
||||||
@@ -125,7 +132,7 @@ try_curl() {
|
|||||||
try_wget() {
|
try_wget() {
|
||||||
command -v wget > /dev/null &&
|
command -v wget > /dev/null &&
|
||||||
if [[ $1 =~ tar.gz$ ]]; then
|
if [[ $1 =~ tar.gz$ ]]; then
|
||||||
wget -O - $1 | tar --no-same-owner -xzf -
|
wget -O - $1 | tar $tar_opts
|
||||||
else
|
else
|
||||||
local temp=${TMPDIR:-/tmp}/fzf.zip
|
local temp=${TMPDIR:-/tmp}/fzf.zip
|
||||||
wget -O "$temp" $1 && unzip -o "$temp" && rm -f "$temp"
|
wget -O "$temp" $1 && unzip -o "$temp" && rm -f "$temp"
|
||||||
@@ -219,12 +226,14 @@ fi
|
|||||||
[[ $* =~ "--bin" ]] && exit 0
|
[[ $* =~ "--bin" ]] && exit 0
|
||||||
|
|
||||||
for s in $shells; do
|
for s in $shells; do
|
||||||
if ! command -v "$s" > /dev/null; then
|
bin=$s
|
||||||
|
[[ $s == nushell ]] && bin=nu
|
||||||
|
if ! command -v "$bin" > /dev/null; then
|
||||||
shells=${shells/$s/}
|
shells=${shells/$s/}
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ ${#shells} -lt 3 ]]; then
|
if [[ -z ${shells// /} ]]; then
|
||||||
echo "No shell configuration to be updated."
|
echo "No shell configuration to be updated."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
@@ -243,6 +252,7 @@ fi
|
|||||||
|
|
||||||
echo
|
echo
|
||||||
for shell in $shells; do
|
for shell in $shells; do
|
||||||
|
[[ $shell == nushell ]] && continue
|
||||||
fzf_completion="source \"$fzf_base/shell/completion.${shell}\""
|
fzf_completion="source \"$fzf_base/shell/completion.${shell}\""
|
||||||
fzf_key_bindings="source \"$fzf_base/shell/key-bindings.${shell}\""
|
fzf_key_bindings="source \"$fzf_base/shell/key-bindings.${shell}\""
|
||||||
[[ $shell == fish ]] && continue
|
[[ $shell == fish ]] && continue
|
||||||
@@ -363,6 +373,7 @@ fi
|
|||||||
echo
|
echo
|
||||||
for shell in $shells; do
|
for shell in $shells; do
|
||||||
[[ $shell == fish ]] && continue
|
[[ $shell == fish ]] && continue
|
||||||
|
[[ $shell == nushell ]] && continue
|
||||||
[ $shell = zsh ] && dest=${ZDOTDIR:-~}/.zshrc || dest=~/.bashrc
|
[ $shell = zsh ] && dest=${ZDOTDIR:-~}/.zshrc || dest=~/.bashrc
|
||||||
append_line $update_config "[ -f ${prefix}.${shell} ] && source ${prefix}.${shell}" "$dest" "${prefix}.${shell}"
|
append_line $update_config "[ -f ${prefix}.${shell} ] && source ${prefix}.${shell}" "$dest" "${prefix}.${shell}"
|
||||||
done
|
done
|
||||||
@@ -431,6 +442,25 @@ if [[ $shells =~ fish ]]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ $shells =~ nushell ]]; then
|
||||||
|
if [[ $key_bindings -eq 1 || $auto_completion -eq 1 ]]; then
|
||||||
|
echo "Setting up Nushell integration ..."
|
||||||
|
nushell_autoload_dir=$(nu -c '$nu.user-autoload-dirs | first')
|
||||||
|
mkdir -p "$nushell_autoload_dir"
|
||||||
|
dest="$nushell_autoload_dir/_fzf_integration.nu"
|
||||||
|
echo -n " Generate $dest ... "
|
||||||
|
if [[ $key_bindings -eq 1 && $auto_completion -eq 1 ]]; then
|
||||||
|
"$fzf_base"/bin/fzf --nushell > "$dest"
|
||||||
|
elif [[ $key_bindings -eq 1 ]]; then
|
||||||
|
cp "$fzf_base/shell/key-bindings.nu" "$dest"
|
||||||
|
else
|
||||||
|
cp "$fzf_base/shell/completion.nu" "$dest"
|
||||||
|
fi
|
||||||
|
echo "OK"
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
if [ $update_config -eq 1 ]; then
|
if [ $update_config -eq 1 ]; then
|
||||||
echo 'Finished. Restart your shell or reload config file.'
|
echo 'Finished. Restart your shell or reload config file.'
|
||||||
if [[ $shells =~ bash ]]; then
|
if [[ $shells =~ bash ]]; then
|
||||||
@@ -439,7 +469,8 @@ if [ $update_config -eq 1 ]; then
|
|||||||
echo
|
echo
|
||||||
fi
|
fi
|
||||||
[[ $shells =~ zsh ]] && echo " source ${ZDOTDIR:-~}/.zshrc # zsh"
|
[[ $shells =~ zsh ]] && echo " source ${ZDOTDIR:-~}/.zshrc # zsh"
|
||||||
[[ $shells =~ fish && $lno_func -ne 0 ]] && echo ' fzf_user_key_bindings # fish'
|
[[ $shells =~ fish && $lno_func -ne 0 ]] && echo ' fish_user_key_bindings # fish'
|
||||||
|
[[ $shells =~ nushell ]] && echo ' # nushell: files are loaded automatically from autoload directory'
|
||||||
echo
|
echo
|
||||||
echo 'Use uninstall script to remove fzf.'
|
echo 'Use uninstall script to remove fzf.'
|
||||||
echo
|
echo
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
$version="0.68.0"
|
$version="0.74.3"
|
||||||
|
|
||||||
$fzf_base=Split-Path -Parent $MyInvocation.MyCommand.Definition
|
$fzf_base=Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"github.com/junegunn/fzf/src/protector"
|
"github.com/junegunn/fzf/src/protector"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "0.68"
|
var version = "0.74"
|
||||||
var revision = "devel"
|
var revision = "devel"
|
||||||
|
|
||||||
//go:embed shell/key-bindings.bash
|
//go:embed shell/key-bindings.bash
|
||||||
@@ -29,6 +29,12 @@ var zshCompletion []byte
|
|||||||
//go:embed shell/key-bindings.fish
|
//go:embed shell/key-bindings.fish
|
||||||
var fishKeyBindings []byte
|
var fishKeyBindings []byte
|
||||||
|
|
||||||
|
//go:embed shell/key-bindings.nu
|
||||||
|
var nushellKeyBindings []byte
|
||||||
|
|
||||||
|
//go:embed shell/completion.nu
|
||||||
|
var nushellCompletion []byte
|
||||||
|
|
||||||
//go:embed shell/completion.fish
|
//go:embed shell/completion.fish
|
||||||
var fishCompletion []byte
|
var fishCompletion []byte
|
||||||
|
|
||||||
@@ -71,6 +77,11 @@ func main() {
|
|||||||
printScript("completion.fish", fishCompletion)
|
printScript("completion.fish", fishCompletion)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if options.Nushell {
|
||||||
|
printScript("key-bindings.nu", nushellKeyBindings)
|
||||||
|
printScript("completion.nu", nushellCompletion)
|
||||||
|
return
|
||||||
|
}
|
||||||
if options.Help {
|
if options.Help {
|
||||||
fmt.Print(fzf.Usage)
|
fmt.Print(fzf.Usage)
|
||||||
return
|
return
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
THE SOFTWARE.
|
THE SOFTWARE.
|
||||||
..
|
..
|
||||||
.TH fzf\-tmux 1 "Feb 2026" "fzf 0.68.0" "fzf\-tmux - open fzf in tmux split pane"
|
.TH fzf\-tmux 1 "Aug 2026" "fzf 0.74.3" "fzf\-tmux - open fzf in tmux split pane"
|
||||||
|
|
||||||
.SH NAME
|
.SH NAME
|
||||||
fzf\-tmux - open fzf in tmux split pane
|
fzf\-tmux - open fzf in tmux split pane
|
||||||
|
|||||||
+236
-23
@@ -21,7 +21,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
THE SOFTWARE.
|
THE SOFTWARE.
|
||||||
..
|
..
|
||||||
.TH fzf 1 "Feb 2026" "fzf 0.68.0" "fzf - a command-line fuzzy finder"
|
.TH fzf 1 "Aug 2026" "fzf 0.74.3" "fzf - a command-line fuzzy finder"
|
||||||
|
|
||||||
.SH NAME
|
.SH NAME
|
||||||
fzf - a command-line fuzzy finder
|
fzf - a command-line fuzzy finder
|
||||||
@@ -134,6 +134,14 @@ e.g.
|
|||||||
# Use template to rearrange fields
|
# Use template to rearrange fields
|
||||||
echo foo,bar,baz | fzf --delimiter , --with-nth '{n},{1},{3},{2},{1..2}'
|
echo foo,bar,baz | fzf --delimiter , --with-nth '{n},{1},{3},{2},{1..2}'
|
||||||
.RE
|
.RE
|
||||||
|
.RS
|
||||||
|
|
||||||
|
\fBchange\-with\-nth\fR action is only available when \fB\-\-with\-nth\fR is set.
|
||||||
|
When \fB\-\-with\-nth\fR is used, fzf retains the original input lines in memory
|
||||||
|
so they can be re\-transformed on the fly (e.g. \fB\-\-with\-nth ..\fR to keep
|
||||||
|
the original presentation). This increases memory usage, so only use
|
||||||
|
\fB\-\-with\-nth\fR when you actually need field transformation.
|
||||||
|
.RE
|
||||||
.TP
|
.TP
|
||||||
.BI "\-\-accept\-nth=" "N[,..] or TEMPLATE"
|
.BI "\-\-accept\-nth=" "N[,..] or TEMPLATE"
|
||||||
Define which fields to print on accept. The last delimiter is stripped from the
|
Define which fields to print on accept. The last delimiter is stripped from the
|
||||||
@@ -218,7 +226,7 @@ Enable processing of ANSI color codes
|
|||||||
Synchronous search for multi-staged filtering. If specified, fzf will launch
|
Synchronous search for multi-staged filtering. If specified, fzf will launch
|
||||||
the finder only after the input stream is complete and the initial filtering
|
the finder only after the input stream is complete and the initial filtering
|
||||||
and the associated actions (bound to any of \fBstart\fR, \fBload\fR,
|
and the associated actions (bound to any of \fBstart\fR, \fBload\fR,
|
||||||
\fBresult\fR, or \fBfocus\fR) are complete.
|
\fBresult\fR, \fBresult\-final\fR, or \fBfocus\fR) are complete.
|
||||||
|
|
||||||
.RS
|
.RS
|
||||||
e.g. \fB# Avoid rendering both fzf instances at the same time
|
e.g. \fB# Avoid rendering both fzf instances at the same time
|
||||||
@@ -376,7 +384,7 @@ Use black background
|
|||||||
|
|
||||||
.SS DISPLAY MODE
|
.SS DISPLAY MODE
|
||||||
.TP
|
.TP
|
||||||
.BI "\-\-height=" "[~]HEIGHT[%]"
|
.BI "\-\-height=" "[~][\-]HEIGHT[%]"
|
||||||
Display fzf window below the cursor with the given height instead of using
|
Display fzf window below the cursor with the given height instead of using
|
||||||
the full screen.
|
the full screen.
|
||||||
|
|
||||||
@@ -386,17 +394,19 @@ height minus the given value.
|
|||||||
fzf \-\-height=\-1
|
fzf \-\-height=\-1
|
||||||
|
|
||||||
When prefixed with \fB~\fR, fzf will automatically determine the height in the
|
When prefixed with \fB~\fR, fzf will automatically determine the height in the
|
||||||
range according to the input size.
|
range according to the input size. You can combine \fB~\fR with a negative
|
||||||
|
value.
|
||||||
|
|
||||||
# Will not take up 100% of the screen
|
# Will not take up 100% of the screen
|
||||||
seq 5 | fzf \-\-height=~100%
|
seq 5 | fzf \-\-height=~100%
|
||||||
|
|
||||||
|
# Adapt to input size, up to terminal height minus 1
|
||||||
|
seq 5 | fzf \-\-height=~\-1
|
||||||
|
|
||||||
Adaptive height has the following limitations:
|
Adaptive height has the following limitations:
|
||||||
.br
|
.br
|
||||||
* Cannot be used with top/bottom margin and padding given in percent size
|
* Cannot be used with top/bottom margin and padding given in percent size
|
||||||
.br
|
.br
|
||||||
* Negative value is not allowed
|
|
||||||
.br
|
|
||||||
* It will not find the right size when there are multi-line items
|
* It will not find the right size when there are multi-line items
|
||||||
|
|
||||||
.TP
|
.TP
|
||||||
@@ -407,25 +417,45 @@ layout options so that the specified number of items are visible in the list
|
|||||||
section (default: \fB10+\fR).
|
section (default: \fB10+\fR).
|
||||||
Ignored when \fB\-\-height\fR is not specified or set as an absolute value.
|
Ignored when \fB\-\-height\fR is not specified or set as an absolute value.
|
||||||
.TP
|
.TP
|
||||||
.BI "\-\-tmux" "[=[center|top|bottom|left|right][,SIZE[%]][,SIZE[%]][,border-native]]"
|
.BI "\-\-popup" "[=[center|top|bottom|left|right][,SIZE[%]][,SIZE[%]][,border-native]]"
|
||||||
Start fzf in a tmux popup (default \fBcenter,50%\fR). Requires tmux 3.3 or
|
Start fzf in a tmux or Zellij floating pane (default \fBcenter,50%\fR).
|
||||||
later. This option is ignored if you are not running fzf inside tmux.
|
Requires tmux 3.3+ or Zellij 0.44+. This option is ignored if you
|
||||||
|
are not running fzf inside tmux or Zellij. \fB\-\-tmux\fR is an alias for this option.
|
||||||
|
|
||||||
|
On tmux 3.7 or above and on Zellij, the floating pane is not modal; you can
|
||||||
|
switch to other panes and windows while fzf is running, and move and resize
|
||||||
|
the pane with the mouse. The native border of the pane is the handle for
|
||||||
|
moving and resizing it, so it is used by default and \fBborder\-native\fR is
|
||||||
|
implied. \fB\-\-border\-label\fR is displayed on the native border, and
|
||||||
|
\fBchange\-border\-label\fR and \fBtransform\-border\-label\fR update it
|
||||||
|
(\fB\-\-border\-label\-pos\fR is ignored). On tmux, fzf holds the label in
|
||||||
|
the \fB@fzf\-border\-label\fR option of the pane and sets its
|
||||||
|
\fBpane\-border\-format\fR to read it back, so the label is displayed if
|
||||||
|
\fBpane\-border\-status\fR is enabled in tmux. The title of the pane is left
|
||||||
|
alone. On Zellij, the label is the name of the pane.
|
||||||
|
|
||||||
|
fzf draws its own border instead when a border style is explicitly specified
|
||||||
|
with \fB\-\-border\fR, so that it is the only border shown. \fBnone\fR and
|
||||||
|
\fBline\fR are treated as no border. Give \fBborder\-native\fR to keep the
|
||||||
|
native border nonetheless. On tmux, the fzf\-drawn border is shown in a modal
|
||||||
|
popup, since the native border of a tmux floating pane cannot be removed;
|
||||||
|
this is also the case on tmux versions below 3.7.
|
||||||
|
|
||||||
e.g.
|
e.g.
|
||||||
\fB# Popup in the center with 70% width and height
|
\fB# Popup in the center with 70% width and height
|
||||||
fzf \-\-tmux 70%
|
fzf \-\-popup 70%
|
||||||
|
|
||||||
# Popup on the left with 40% width and 100% height
|
# Popup on the left with 40% width and 100% height
|
||||||
fzf \-\-tmux right,40%
|
fzf \-\-popup right,40%
|
||||||
|
|
||||||
# Popup on the bottom with 100% width and 30% height
|
# Popup on the bottom with 100% width and 30% height
|
||||||
fzf \-\-tmux bottom,30%
|
fzf \-\-popup bottom,30%
|
||||||
|
|
||||||
# Popup on the top with 80% width and 40% height
|
# Popup on the top with 80% width and 40% height
|
||||||
fzf \-\-tmux top,80%,40%
|
fzf \-\-popup top,80%,40%
|
||||||
|
|
||||||
# Popup with a native tmux border in the center with 80% width and height
|
# Popup with a native tmux or Zellij border in the center with 80% width and height
|
||||||
fzf \-\-tmux center,80%,border\-native\fR
|
fzf \-\-popup center,80%,border\-native\fR
|
||||||
|
|
||||||
.SS LAYOUT
|
.SS LAYOUT
|
||||||
.TP
|
.TP
|
||||||
@@ -506,6 +536,8 @@ Draw border around the finder
|
|||||||
.br
|
.br
|
||||||
.BR double " Border with double lines"
|
.BR double " Border with double lines"
|
||||||
.br
|
.br
|
||||||
|
.BR dashed " Border with dashed lines and rounded corners"
|
||||||
|
.br
|
||||||
.BR block " Border using block elements; suitable when using different background colors"
|
.BR block " Border using block elements; suitable when using different background colors"
|
||||||
.br
|
.br
|
||||||
.BR thinblock " Border using legacy computing symbols; may not be displayed on some terminals"
|
.BR thinblock " Border using legacy computing symbols; may not be displayed on some terminals"
|
||||||
@@ -609,17 +641,53 @@ Disable multi-line display of items when using \fB\-\-read0\fR
|
|||||||
.B "\-\-raw"
|
.B "\-\-raw"
|
||||||
Enable raw mode where non-matching items are also displayed in a dimmed color.
|
Enable raw mode where non-matching items are also displayed in a dimmed color.
|
||||||
.TP
|
.TP
|
||||||
.B "\-\-track"
|
.BI "\-\-track"
|
||||||
Make fzf track the current selection when the result list is updated.
|
Make fzf track the current selection when the result list is updated.
|
||||||
This can be useful when browsing logs using fzf with sorting disabled. It is
|
This can be useful when browsing logs using fzf with sorting disabled. It is
|
||||||
not recommended to use this option with \fB\-\-tac\fR as the resulting behavior
|
not recommended to use this option with \fB\-\-tac\fR as the resulting behavior
|
||||||
can be confusing. Also, consider using \fBtrack\fR action instead of this
|
can be confusing.
|
||||||
option.
|
|
||||||
|
When \fB\-\-id\-nth\fR is also set, fzf enables field\-based tracking across
|
||||||
|
\fBreload\fRs. See \fB\-\-id\-nth\fR for details.
|
||||||
|
|
||||||
|
Without \fB\-\-id\-nth\fR, \fB\-\-track\fR uses index\-based tracking that
|
||||||
|
does not persist across reloads.
|
||||||
|
|
||||||
.RS
|
.RS
|
||||||
e.g.
|
e.g.
|
||||||
\fBgit log \-\-oneline \-\-graph \-\-color=always | nl |
|
\fB# Index\-based tracking (does not persist across reloads)
|
||||||
|
git log \-\-oneline \-\-graph \-\-color=always | nl |
|
||||||
fzf \-\-ansi \-\-track \-\-no\-sort \-\-layout=reverse\-list\fR
|
fzf \-\-ansi \-\-track \-\-no\-sort \-\-layout=reverse\-list\fR
|
||||||
|
|
||||||
|
\fB# Track by first field (e.g. pod name) across reloads
|
||||||
|
kubectl get pods | fzf \-\-track \-\-id\-nth 1 \-\-header\-lines=1 \\
|
||||||
|
\-\-bind 'ctrl\-r:reload:kubectl get pods'\fR
|
||||||
|
.RE
|
||||||
|
.TP
|
||||||
|
.BI "\-\-id\-nth=" "N[,..]"
|
||||||
|
Define item identity fields for cross\-reload operations. When set, fzf
|
||||||
|
uses the specified fields to identify items across \fBreload\fR and
|
||||||
|
\fBreload\-sync\fR.
|
||||||
|
|
||||||
|
With \fB\-\-track\fR, fzf extracts the tracking key from the current item
|
||||||
|
using the nth expression and searches for a matching item in the reloaded list.
|
||||||
|
While searching, the UI is blocked (query input and cursor movement are
|
||||||
|
disabled, and the prompt is dimmed). With \fBreload\fR, the blocked state
|
||||||
|
clears as soon as the match is found in the stream. With \fBreload\-sync\fR,
|
||||||
|
the blocked state persists until the entire stream is complete. Press
|
||||||
|
\fBEscape\fR or \fBCtrl\-C\fR to cancel the blocked state without quitting fzf.
|
||||||
|
|
||||||
|
The info line shows \fB+T*\fR (or \fB+t*\fR for one\-off tracking) while
|
||||||
|
the search is in progress.
|
||||||
|
|
||||||
|
With \fB\-\-multi\fR, selected items are preserved across \fBreload\-sync\fR
|
||||||
|
by matching their identity fields in the reloaded list.
|
||||||
|
|
||||||
|
.RS
|
||||||
|
e.g.
|
||||||
|
\fB# Track and preserve selections by pod name across reloads
|
||||||
|
kubectl get pods | fzf \-\-multi \-\-track \-\-id\-nth 1 \-\-header\-lines=1 \\
|
||||||
|
\-\-bind 'ctrl\-r:reload\-sync:kubectl get pods'\fR
|
||||||
.RE
|
.RE
|
||||||
.TP
|
.TP
|
||||||
.B "\-\-tac"
|
.B "\-\-tac"
|
||||||
@@ -759,6 +827,10 @@ A synonym for \fB\-\-info=hidden\fB
|
|||||||
The given string will be repeated to form the horizontal separator on the info
|
The given string will be repeated to form the horizontal separator on the info
|
||||||
line (default: '─' or '\-' depending on \fB\-\-no\-unicode\fR).
|
line (default: '─' or '\-' depending on \fB\-\-no\-unicode\fR).
|
||||||
|
|
||||||
|
Unless explicitly specified, the separator is not displayed if the input
|
||||||
|
section is already visually separated from the list section by a border line
|
||||||
|
(e.g. \fB\-\-input\-border\fR or \fB\-\-header\-border\fR).
|
||||||
|
|
||||||
ANSI color codes are supported.
|
ANSI color codes are supported.
|
||||||
|
|
||||||
.TP
|
.TP
|
||||||
@@ -908,6 +980,8 @@ Should be used with one of the following \fB\-\-preview\-window\fR options.
|
|||||||
.br
|
.br
|
||||||
.B * border\-double
|
.B * border\-double
|
||||||
.br
|
.br
|
||||||
|
.B * border\-dashed
|
||||||
|
.br
|
||||||
.B * border\-block
|
.B * border\-block
|
||||||
.br
|
.br
|
||||||
.B * border\-thinblock
|
.B * border\-thinblock
|
||||||
@@ -942,9 +1016,14 @@ border line.
|
|||||||
\fBdown
|
\fBdown
|
||||||
\fBleft
|
\fBleft
|
||||||
\fBright
|
\fBright
|
||||||
|
\fBnext
|
||||||
|
|
||||||
\fRDetermines the layout of the preview window.
|
\fRDetermines the layout of the preview window.
|
||||||
|
|
||||||
|
* \fBnext\fR places the preview window adjacent to the input section, on
|
||||||
|
the list side: above the input in the default layout, below the input
|
||||||
|
in \fB\-\-layout=reverse\fR.
|
||||||
|
|
||||||
* If the argument contains \fB:hidden\fR, the preview window will be hidden by
|
* If the argument contains \fB:hidden\fR, the preview window will be hidden by
|
||||||
default until \fBtoggle\-preview\fR action is triggered.
|
default until \fBtoggle\-preview\fR action is triggered.
|
||||||
|
|
||||||
@@ -1053,7 +1132,17 @@ Print header before the prompt line. When both normal header and header lines
|
|||||||
.TP
|
.TP
|
||||||
.BI "\-\-header\-border" [=STYLE]
|
.BI "\-\-header\-border" [=STYLE]
|
||||||
Draw border around the header section. \fBline\fR style draws a single
|
Draw border around the header section. \fBline\fR style draws a single
|
||||||
separator line between the header window and the list section.
|
separator line between the header window and the list section. \fBinline\fR
|
||||||
|
style embeds the header inside the list border frame, joined to the list
|
||||||
|
section by a horizontal separator; it requires a \fB\-\-list\-border\fR
|
||||||
|
shape that has both top and bottom segments (rounded / sharp / bold /
|
||||||
|
double / dashed / block / thinblock / horizontal) and falls back to \fBline\fR
|
||||||
|
otherwise. When the list border also has side segments, the separator
|
||||||
|
joins them with T-junctions; \fBhorizontal\fR has no side borders, so the
|
||||||
|
separator is drawn without T-junction endpoints. Takes precedence over
|
||||||
|
\fB\-\-header\-first\fR (the section stays inside the list frame), and
|
||||||
|
when \fB\-\-header\-lines\fR is also set \fB\-\-header\-lines\-border\fR
|
||||||
|
must also be \fBinline\fR.
|
||||||
|
|
||||||
.TP
|
.TP
|
||||||
.BI "\-\-header\-label" [=LABEL]
|
.BI "\-\-header\-label" [=LABEL]
|
||||||
@@ -1069,6 +1158,10 @@ Display header from \fB--header\-lines\fR with a separate border. Pass
|
|||||||
\fBnone\fR to still separate the header lines but without a border. To combine
|
\fBnone\fR to still separate the header lines but without a border. To combine
|
||||||
two headers, use \fB\-\-no\-header\-lines\-border\fR. \fBline\fR style draws
|
two headers, use \fB\-\-no\-header\-lines\-border\fR. \fBline\fR style draws
|
||||||
a single separator line between the header lines and the list section.
|
a single separator line between the header lines and the list section.
|
||||||
|
\fBinline\fR style embeds the header lines inside the list border frame
|
||||||
|
with a horizontal separator; it requires a \fB\-\-list\-border\fR shape
|
||||||
|
that has both top and bottom segments, falls back to \fBline\fR
|
||||||
|
otherwise.
|
||||||
|
|
||||||
.SS FOOTER
|
.SS FOOTER
|
||||||
|
|
||||||
@@ -1082,7 +1175,10 @@ are not affected by \fB\-\-with\-nth\fR. ANSI color codes are processed even whe
|
|||||||
.TP
|
.TP
|
||||||
.BI "\-\-footer\-border" [=STYLE]
|
.BI "\-\-footer\-border" [=STYLE]
|
||||||
Draw border around the footer section. \fBline\fR style draws a single
|
Draw border around the footer section. \fBline\fR style draws a single
|
||||||
separator line between the footer and the list section.
|
separator line between the footer and the list section. \fBinline\fR style
|
||||||
|
embeds the footer inside the list border frame with a horizontal separator;
|
||||||
|
it requires a \fB\-\-list\-border\fR shape that has both top and bottom
|
||||||
|
segments and falls back to \fBline\fR otherwise.
|
||||||
|
|
||||||
.TP
|
.TP
|
||||||
.BI "\-\-footer\-label" [=LABEL]
|
.BI "\-\-footer\-label" [=LABEL]
|
||||||
@@ -1229,6 +1325,18 @@ Here is an example script that uses a Unix socket instead of a TCP port.
|
|||||||
curl --unix-socket /tmp/fzf.sock http -d up
|
curl --unix-socket /tmp/fzf.sock http -d up
|
||||||
\fR
|
\fR
|
||||||
|
|
||||||
|
.TP
|
||||||
|
.BI "\-\-threads=" "N"
|
||||||
|
Number of matcher threads to use. The default value is
|
||||||
|
\fBmin(8 * NUM_CPU, 32)\fR.
|
||||||
|
.TP
|
||||||
|
.BI "\-\-bench=" "DURATION"
|
||||||
|
Repeatedly run \fB\-\-filter\fR for the given duration and print timing
|
||||||
|
statistics. Must be used with \fB\-\-filter\fR.
|
||||||
|
|
||||||
|
e.g.
|
||||||
|
\fBcat /usr/share/dict/words | fzf \-\-filter abc \-\-bench 10s\fR
|
||||||
|
|
||||||
.SS DIRECTORY TRAVERSAL
|
.SS DIRECTORY TRAVERSAL
|
||||||
.TP
|
.TP
|
||||||
.B "\-\-walker=[file][,dir][,follow][,hidden]"
|
.B "\-\-walker=[file][,dir][,follow][,hidden]"
|
||||||
@@ -1288,6 +1396,12 @@ Print script to set up Fish shell integration
|
|||||||
|
|
||||||
e.g. \fBfzf \-\-fish | source\fR
|
e.g. \fBfzf \-\-fish | source\fR
|
||||||
|
|
||||||
|
.TP
|
||||||
|
.B "\-\-nushell"
|
||||||
|
Print script to set up Nushell shell integration
|
||||||
|
|
||||||
|
e.g. \fBfzf \-\-nushell | save \-f ~/.config/nushell/autoload/_fzf_integration.nu\fR
|
||||||
|
|
||||||
.SS OTHERS
|
.SS OTHERS
|
||||||
.TP
|
.TP
|
||||||
.B "\-\-no\-mouse"
|
.B "\-\-no\-mouse"
|
||||||
@@ -1390,6 +1504,8 @@ fzf exports the following environment variables to its child processes.
|
|||||||
.br
|
.br
|
||||||
.BR FZF_POS " Vertical position of the cursor in the list starting from 1"
|
.BR FZF_POS " Vertical position of the cursor in the list starting from 1"
|
||||||
.br
|
.br
|
||||||
|
.BR FZF_CURRENT_ITEM " Text of the current item (unset if the list is empty)"
|
||||||
|
.br
|
||||||
.BR FZF_WRAP " The line wrapping mode (char, word) when enabled"
|
.BR FZF_WRAP " The line wrapping mode (char, word) when enabled"
|
||||||
.br
|
.br
|
||||||
.BR FZF_QUERY " Current query string"
|
.BR FZF_QUERY " Current query string"
|
||||||
@@ -1398,6 +1514,8 @@ fzf exports the following environment variables to its child processes.
|
|||||||
.br
|
.br
|
||||||
.BR FZF_NTH " Current \-\-nth option"
|
.BR FZF_NTH " Current \-\-nth option"
|
||||||
.br
|
.br
|
||||||
|
.BR FZF_WITH_NTH " Current \-\-with\-nth option"
|
||||||
|
.br
|
||||||
.BR FZF_PROMPT " Prompt string"
|
.BR FZF_PROMPT " Prompt string"
|
||||||
.br
|
.br
|
||||||
.BR FZF_GHOST " Ghost string"
|
.BR FZF_GHOST " Ghost string"
|
||||||
@@ -1418,6 +1536,10 @@ fzf exports the following environment variables to its child processes.
|
|||||||
.br
|
.br
|
||||||
.BR FZF_KEY " The name of the last key pressed"
|
.BR FZF_KEY " The name of the last key pressed"
|
||||||
.br
|
.br
|
||||||
|
.BR FZF_IDLE_TIME " Whole seconds since the last user activity"
|
||||||
|
.br
|
||||||
|
.BR FZF_IDLE_TIME_MS " Milliseconds since the last user activity"
|
||||||
|
.br
|
||||||
.BR FZF_PORT " Port number when \-\-listen option is used"
|
.BR FZF_PORT " Port number when \-\-listen option is used"
|
||||||
.br
|
.br
|
||||||
.BR FZF_SOCK " Unix socket path when \-\-listen option is used"
|
.BR FZF_SOCK " Unix socket path when \-\-listen option is used"
|
||||||
@@ -1432,6 +1554,13 @@ fzf exports the following environment variables to its child processes.
|
|||||||
.br
|
.br
|
||||||
.BR FZF_RAW " Only in raw mode. 1 if the current item matches, 0 otherwise"
|
.BR FZF_RAW " Only in raw mode. 1 if the current item matches, 0 otherwise"
|
||||||
|
|
||||||
|
.PP
|
||||||
|
.B FZF_CURRENT_ITEM
|
||||||
|
is omitted when the item contains a NUL byte, because exec(2) cannot pass it.
|
||||||
|
It is also omitted when the item is larger than 64 KB, so that a huge item
|
||||||
|
cannot overflow the environment size limit and break preview and other child
|
||||||
|
commands.
|
||||||
|
|
||||||
.SH EXTENDED SEARCH MODE
|
.SH EXTENDED SEARCH MODE
|
||||||
|
|
||||||
Unless specified otherwise, fzf will start in "extended\-search mode". In this
|
Unless specified otherwise, fzf will start in "extended\-search mode". In this
|
||||||
@@ -1752,6 +1881,20 @@ e.g.
|
|||||||
# * Note that you can't use 'change' event in this case because the second position may not be available
|
# * Note that you can't use 'change' event in this case because the second position may not be available
|
||||||
fzf \-\-sync \-\-bind 'result:transform:[[ \-z {q} ]] && echo "pos(2)"'\fR
|
fzf \-\-sync \-\-bind 'result:transform:[[ \-z {q} ]] && echo "pos(2)"'\fR
|
||||||
.RE
|
.RE
|
||||||
|
|
||||||
|
\fIresult\-final\fR
|
||||||
|
.RS
|
||||||
|
Same as \fIresult\fR, but suppressed while the input stream is still open. Use
|
||||||
|
this when you want a one-shot action per query instead of one per intermediate
|
||||||
|
snapshot during loading.
|
||||||
|
|
||||||
|
e.g.
|
||||||
|
\fB# 'result' fires per intermediate snapshot (header keeps updating during load);
|
||||||
|
# 'result-final' fires once after the stream closes (footer shows the final count)
|
||||||
|
(seq 100; sleep 1; seq 100) | fzf \-\-query 1 \\
|
||||||
|
\-\-bind 'result:transform\-header(echo result: $FZF_MATCH_COUNT),result\-final:transform\-footer(echo final: $FZF_MATCH_COUNT)'\fR
|
||||||
|
.RE
|
||||||
|
|
||||||
\fIchange\fR
|
\fIchange\fR
|
||||||
.RS
|
.RS
|
||||||
Triggered whenever the query string is changed
|
Triggered whenever the query string is changed
|
||||||
@@ -1857,6 +2000,30 @@ variables starting from 1. It optionally sets \fBFZF_CLICK_FOOTER_WORD\fR
|
|||||||
if clicked on a word.
|
if clicked on a word.
|
||||||
.RE
|
.RE
|
||||||
|
|
||||||
|
\fIevery(N)\fR
|
||||||
|
.RS
|
||||||
|
Triggered every \fIN\fR seconds (\fIN\fR can be a fractional number, e.g.
|
||||||
|
\fB0.5\fR). The minimum interval is \fB0.01\fR seconds; values are floored
|
||||||
|
to that.
|
||||||
|
|
||||||
|
Combine with the \fBFZF_IDLE_TIME\fR (whole seconds) and
|
||||||
|
\fBFZF_IDLE_TIME_MS\fR (milliseconds) environment variables to build
|
||||||
|
idle\-based behavior without a separate event.
|
||||||
|
|
||||||
|
e.g.
|
||||||
|
\fB# Live process list, refreshed every 2 seconds.
|
||||||
|
# --track --id-nth 2 keeps the cursor on the same PID across reloads.
|
||||||
|
fzf \-\-header\-lines 1 \-\-track \-\-id\-nth 2 \\
|
||||||
|
\-\-bind 'start,every(2):reload\-sync:ps \-ef'
|
||||||
|
|
||||||
|
# Auto\-accept after 10 seconds of inactivity, with a countdown in the footer after 5s.
|
||||||
|
fzf \-\-bind 'every(1):bg\-transform:
|
||||||
|
if [[ $FZF_IDLE_TIME \-lt 5 ]]; then echo change\-footer:
|
||||||
|
elif [[ $FZF_IDLE_TIME \-lt 10 ]]; then echo "change\-footer:auto\-accept in $((10 \- FZF_IDLE_TIME))s"
|
||||||
|
else echo accept
|
||||||
|
fi'\fR
|
||||||
|
.RE
|
||||||
|
|
||||||
.SS AVAILABLE ACTIONS:
|
.SS AVAILABLE ACTIONS:
|
||||||
A key or an event can be bound to one or more of the following actions.
|
A key or an event can be bound to one or more of the following actions.
|
||||||
|
|
||||||
@@ -1871,7 +2038,7 @@ A key or an event can be bound to one or more of the following actions.
|
|||||||
\fBbackward\-kill\-subword\fR
|
\fBbackward\-kill\-subword\fR
|
||||||
\fBbackward\-kill\-word\fR \fIalt\-bs\fR
|
\fBbackward\-kill\-word\fR \fIalt\-bs\fR
|
||||||
\fBbackward\-subword\fR
|
\fBbackward\-subword\fR
|
||||||
\fBbackward\-word\fR \fIalt\-b shift\-left\fR
|
\fBbackward\-word\fR \fIalt\-b shift\-left alt\-left\fR
|
||||||
\fBbecome(...)\fR (replace fzf process with the specified command; see below for the details)
|
\fBbecome(...)\fR (replace fzf process with the specified command; see below for the details)
|
||||||
\fBbeginning\-of\-line\fR \fIctrl\-a home\fR
|
\fBbeginning\-of\-line\fR \fIctrl\-a home\fR
|
||||||
\fBbell\fR (ring the terminal bell)
|
\fBbell\fR (ring the terminal bell)
|
||||||
@@ -1881,12 +2048,14 @@ A key or an event can be bound to one or more of the following actions.
|
|||||||
\fBchange\-border\-label(...)\fR (change \fB\-\-border\-label\fR to the given string)
|
\fBchange\-border\-label(...)\fR (change \fB\-\-border\-label\fR to the given string)
|
||||||
\fBchange\-ghost(...)\fR (change ghost text to the given string)
|
\fBchange\-ghost(...)\fR (change ghost text to the given string)
|
||||||
\fBchange\-header(...)\fR (change header to the given string; doesn't affect \fB\-\-header\-lines\fR)
|
\fBchange\-header(...)\fR (change header to the given string; doesn't affect \fB\-\-header\-lines\fR)
|
||||||
|
\fBchange\-header\-lines(N)\fR (change the number of \fB\-\-header\-lines\fR)
|
||||||
\fBchange\-header\-label(...)\fR (change \fB\-\-header\-label\fR to the given string)
|
\fBchange\-header\-label(...)\fR (change \fB\-\-header\-label\fR to the given string)
|
||||||
\fBchange\-input\-label(...)\fR (change \fB\-\-input\-label\fR to the given string)
|
\fBchange\-input\-label(...)\fR (change \fB\-\-input\-label\fR to the given string)
|
||||||
\fBchange\-list\-label(...)\fR (change \fB\-\-list\-label\fR to the given string)
|
\fBchange\-list\-label(...)\fR (change \fB\-\-list\-label\fR to the given string)
|
||||||
\fBchange\-multi\fR (enable multi-select mode with no limit)
|
\fBchange\-multi\fR (enable multi-select mode with no limit)
|
||||||
\fBchange\-multi(...)\fR (enable multi-select mode with a limit or disable it with 0)
|
\fBchange\-multi(...)\fR (enable multi-select mode with a limit or disable it with 0)
|
||||||
\fBchange\-nth(...)\fR (change \fB\-\-nth\fR option; rotate through the multiple options separated by '|')
|
\fBchange\-nth(...)\fR (change \fB\-\-nth\fR option; rotate through the multiple options separated by '|')
|
||||||
|
\fBchange\-with\-nth(...)\fR (change \fB\-\-with\-nth\fR option; rotate through the multiple options separated by '|')
|
||||||
\fBchange\-pointer(...)\fR (change \fB\-\-pointer\fR option)
|
\fBchange\-pointer(...)\fR (change \fB\-\-pointer\fR option)
|
||||||
\fBchange\-preview(...)\fR (change \fB\-\-preview\fR option)
|
\fBchange\-preview(...)\fR (change \fB\-\-preview\fR option)
|
||||||
\fBchange\-preview\-label(...)\fR (change \fB\-\-preview\-label\fR to the given string)
|
\fBchange\-preview\-label(...)\fR (change \fB\-\-preview\-label\fR to the given string)
|
||||||
@@ -1916,7 +2085,7 @@ A key or an event can be bound to one or more of the following actions.
|
|||||||
\fBfirst\fR (move to the first match; same as \fBpos(1)\fR)
|
\fBfirst\fR (move to the first match; same as \fBpos(1)\fR)
|
||||||
\fBforward\-char\fR \fIctrl\-f right\fR
|
\fBforward\-char\fR \fIctrl\-f right\fR
|
||||||
\fBforward\-subword\fR
|
\fBforward\-subword\fR
|
||||||
\fBforward\-word\fR \fIalt\-f shift\-right\fR
|
\fBforward\-word\fR \fIalt\-f shift\-right alt\-right\fR
|
||||||
\fBignore\fR
|
\fBignore\fR
|
||||||
\fBjump\fR (EasyMotion-like 2-keystroke movement)
|
\fBjump\fR (EasyMotion-like 2-keystroke movement)
|
||||||
\fBkill\-line\fR
|
\fBkill\-line\fR
|
||||||
@@ -1987,10 +2156,12 @@ A key or an event can be bound to one or more of the following actions.
|
|||||||
\fBtransform\-border\-label(...)\fR (transform border label using an external command)
|
\fBtransform\-border\-label(...)\fR (transform border label using an external command)
|
||||||
\fBtransform\-ghost(...)\fR (transform ghost text using an external command)
|
\fBtransform\-ghost(...)\fR (transform ghost text using an external command)
|
||||||
\fBtransform\-header(...)\fR (transform header using an external command)
|
\fBtransform\-header(...)\fR (transform header using an external command)
|
||||||
|
\fBtransform\-header\-lines(...)\fR (transform the number of \fB\-\-header\-lines\fR using an external command)
|
||||||
\fBtransform\-header\-label(...)\fR (transform header label using an external command)
|
\fBtransform\-header\-label(...)\fR (transform header label using an external command)
|
||||||
\fBtransform\-input\-label(...)\fR (transform input label using an external command)
|
\fBtransform\-input\-label(...)\fR (transform input label using an external command)
|
||||||
\fBtransform\-list\-label(...)\fR (transform list label using an external command)
|
\fBtransform\-list\-label(...)\fR (transform list label using an external command)
|
||||||
\fBtransform\-nth(...)\fR (transform nth using an external command)
|
\fBtransform\-nth(...)\fR (transform nth using an external command)
|
||||||
|
\fBtransform\-with\-nth(...)\fR (transform with-nth using an external command)
|
||||||
\fBtransform\-pointer(...)\fR (transform pointer using an external command)
|
\fBtransform\-pointer(...)\fR (transform pointer using an external command)
|
||||||
\fBtransform\-preview\-label(...)\fR (transform preview label using an external command)
|
\fBtransform\-preview\-label(...)\fR (transform preview label using an external command)
|
||||||
\fBtransform\-prompt(...)\fR (transform prompt string using an external command)
|
\fBtransform\-prompt(...)\fR (transform prompt string using an external command)
|
||||||
@@ -2001,6 +2172,7 @@ A key or an event can be bound to one or more of the following actions.
|
|||||||
\fBunix\-line\-discard\fR \fIctrl\-u\fR
|
\fBunix\-line\-discard\fR \fIctrl\-u\fR
|
||||||
\fBunix\-word\-rubout\fR \fIctrl\-w\fR
|
\fBunix\-word\-rubout\fR \fIctrl\-w\fR
|
||||||
\fBuntrack\-current\fR (stop tracking the current item; no-op if global tracking is enabled)
|
\fBuntrack\-current\fR (stop tracking the current item; no-op if global tracking is enabled)
|
||||||
|
\fBwait\fR (block action execution until search completes)
|
||||||
\fBup\fR \fIctrl\-k up\fR
|
\fBup\fR \fIctrl\-k up\fR
|
||||||
\fBup\-match\fR \fIctrl\-p\fR \fIalt\-up\fR (move to the match above the cursor)
|
\fBup\-match\fR \fIctrl\-p\fR \fIalt\-up\fR (move to the match above the cursor)
|
||||||
\fBup\-selected\fR (move to the selected item above the cursor)
|
\fBup\-selected\fR (move to the selected item above the cursor)
|
||||||
@@ -2153,6 +2325,47 @@ chain multiple transform actions where later ones depend on earlier results,
|
|||||||
prefer using the \fBbg\fR variant. To cancel currently running background
|
prefer using the \fBbg\fR variant. To cancel currently running background
|
||||||
transform processes, use \fBbg\-cancel\fR action.
|
transform processes, use \fBbg\-cancel\fR action.
|
||||||
|
|
||||||
|
.SS WAITING FOR SEARCH COMPLETION
|
||||||
|
|
||||||
|
The \fBwait\fR action blocks the execution of subsequent actions until the
|
||||||
|
current search completes. This is useful when chaining query\-changing actions
|
||||||
|
with motion actions like \fBbest\fR or \fBfirst\fR, ensuring that the motion
|
||||||
|
action operates on the complete search results rather than stale data.
|
||||||
|
|
||||||
|
e.g.
|
||||||
|
\fBfzf \-\-bind 'start:change\-query(foo)+wait+best'\fR
|
||||||
|
|
||||||
|
In this example, \fBchange\-query(foo)\fR starts an asynchronous search for
|
||||||
|
the new query, \fBwait\fR blocks until the search completes, and \fBbest\fR
|
||||||
|
then moves the cursor to the best match in the complete result set.
|
||||||
|
|
||||||
|
The initial loading of the input is also considered a search in progress, so
|
||||||
|
\fBstart:wait\fR can be used to block until the input is fully loaded and
|
||||||
|
searched.
|
||||||
|
|
||||||
|
While waiting, user input is ignored, except for keys bound to \fBabort\fR or
|
||||||
|
\fBcancel\fR (\fIctrl\-c\fR, \fIctrl\-g\fR, \fIctrl\-q\fR, and \fIesc\fR by
|
||||||
|
default), which cancel the wait and discard the pending actions instead of
|
||||||
|
performing their usual role. The remaining actions of such a binding still run,
|
||||||
|
so a binding like \fBesc:cancel+first\fR is possible.
|
||||||
|
|
||||||
|
Asynchronous \fBbg\-transform\-*\fR actions are not affected; their results are
|
||||||
|
applied as soon as they arrive, even while waiting. For the same reason,
|
||||||
|
\fBwait\fR does not pair with them: in
|
||||||
|
\fBbg\-transform\-query(...)+wait\fR, the background command completes only
|
||||||
|
after \fBwait\fR has already been evaluated, so the search its result
|
||||||
|
eventually triggers is not waited for. Use the synchronous
|
||||||
|
\fBtransform\-query(...)\fR variant instead when chaining with \fBwait\fR.
|
||||||
|
|
||||||
|
If the search takes long enough, fzf indicates that it is waiting by dimming the
|
||||||
|
input, hiding the cursor, and showing \fB(..)\fR on the info line. This visual
|
||||||
|
feedback is debounced so that quick searches do not cause flickering.
|
||||||
|
|
||||||
|
Note that when searches are triggered in rapid succession (e.g. via
|
||||||
|
\fB\-\-listen\fR), \fBwait\fR may unblock on the completion of an earlier
|
||||||
|
search. Also, if the input source never completes, \fBwait\fR will block until
|
||||||
|
cancelled.
|
||||||
|
|
||||||
.SS PREVIEW BINDING
|
.SS PREVIEW BINDING
|
||||||
|
|
||||||
With \fBpreview(...)\fR action, you can specify multiple different preview
|
With \fBpreview(...)\fR action, you can specify multiple different preview
|
||||||
|
|||||||
+57
-25
@@ -896,6 +896,7 @@ function! s:execute_term(dict, command, temps) abort
|
|||||||
endif
|
endif
|
||||||
endfunction
|
endfunction
|
||||||
function! fzf.on_exit(id, code, ...)
|
function! fzf.on_exit(id, code, ...)
|
||||||
|
silent! autocmd! fzf_popup_resize
|
||||||
if s:getpos() == self.ppos " {'window': 'enew'}
|
if s:getpos() == self.ppos " {'window': 'enew'}
|
||||||
for [opt, val] in items(self.winopts)
|
for [opt, val] in items(self.winopts)
|
||||||
execute 'let' opt '=' val
|
execute 'let' opt '=' val
|
||||||
@@ -1023,15 +1024,17 @@ function! s:callback(dict, lines) abort
|
|||||||
endfunction
|
endfunction
|
||||||
|
|
||||||
if has('nvim')
|
if has('nvim')
|
||||||
function s:create_popup(opts) abort
|
function! s:create_popup() abort
|
||||||
|
let opts = s:popup_bounds()
|
||||||
|
let opts = extend({'relative': 'editor', 'style': 'minimal'}, opts)
|
||||||
|
|
||||||
let buf = nvim_create_buf(v:false, v:true)
|
let buf = nvim_create_buf(v:false, v:true)
|
||||||
let opts = extend({'relative': 'editor', 'style': 'minimal'}, a:opts)
|
let s:popup_id = nvim_open_win(buf, v:true, opts)
|
||||||
let win = nvim_open_win(buf, v:true, opts)
|
call setwinvar(s:popup_id, '&colorcolumn', '')
|
||||||
call setwinvar(win, '&colorcolumn', '')
|
|
||||||
|
|
||||||
" Colors
|
" Colors
|
||||||
try
|
try
|
||||||
call setwinvar(win, '&winhighlight', 'Pmenu:,Normal:Normal')
|
call setwinvar(s:popup_id, '&winhighlight', 'Pmenu:,Normal:Normal')
|
||||||
let rules = get(g:, 'fzf_colors', {})
|
let rules = get(g:, 'fzf_colors', {})
|
||||||
if has_key(rules, 'bg')
|
if has_key(rules, 'bg')
|
||||||
let color = call('s:get_color', rules.bg)
|
let color = call('s:get_color', rules.bg)
|
||||||
@@ -1039,40 +1042,61 @@ if has('nvim')
|
|||||||
let ns = nvim_create_namespace('fzf_popup')
|
let ns = nvim_create_namespace('fzf_popup')
|
||||||
let hl = nvim_set_hl(ns, 'Normal',
|
let hl = nvim_set_hl(ns, 'Normal',
|
||||||
\ &termguicolors ? { 'bg': color } : { 'ctermbg': str2nr(color) })
|
\ &termguicolors ? { 'bg': color } : { 'ctermbg': str2nr(color) })
|
||||||
call nvim_win_set_hl_ns(win, ns)
|
call nvim_win_set_hl_ns(s:popup_id, ns)
|
||||||
endif
|
endif
|
||||||
endif
|
endif
|
||||||
catch
|
catch
|
||||||
endtry
|
endtry
|
||||||
return buf
|
return buf
|
||||||
endfunction
|
endfunction
|
||||||
|
|
||||||
|
function! s:resize_popup() abort
|
||||||
|
if !exists('s:popup_id') || !nvim_win_is_valid(s:popup_id)
|
||||||
|
return
|
||||||
|
endif
|
||||||
|
let opts = s:popup_bounds()
|
||||||
|
let opts = extend({'relative': 'editor'}, opts)
|
||||||
|
call nvim_win_set_config(s:popup_id, opts)
|
||||||
|
endfunction
|
||||||
else
|
else
|
||||||
function! s:create_popup(opts) abort
|
function! s:create_popup() abort
|
||||||
let s:popup_create = {buf -> popup_create(buf, #{
|
function! s:popup_create(buf)
|
||||||
\ line: a:opts.row,
|
let s:popup_id = popup_create(a:buf, #{zindex: 1000})
|
||||||
\ col: a:opts.col,
|
call s:resize_popup()
|
||||||
\ minwidth: a:opts.width,
|
endfunction
|
||||||
\ maxwidth: a:opts.width,
|
|
||||||
\ minheight: a:opts.height,
|
|
||||||
\ maxheight: a:opts.height,
|
|
||||||
\ zindex: 1000,
|
|
||||||
\ })}
|
|
||||||
autocmd TerminalOpen * ++once call s:popup_create(str2nr(expand('<abuf>')))
|
autocmd TerminalOpen * ++once call s:popup_create(str2nr(expand('<abuf>')))
|
||||||
endfunction
|
endfunction
|
||||||
|
|
||||||
|
function! s:resize_popup() abort
|
||||||
|
if !exists('s:popup_id') || empty(popup_getpos(s:popup_id))
|
||||||
|
return
|
||||||
|
endif
|
||||||
|
let opts = s:popup_bounds()
|
||||||
|
call popup_move(s:popup_id, {
|
||||||
|
\ 'line': opts.row,
|
||||||
|
\ 'col': opts.col,
|
||||||
|
\ 'minwidth': opts.width,
|
||||||
|
\ 'maxwidth': opts.width,
|
||||||
|
\ 'minheight': opts.height,
|
||||||
|
\ 'maxheight': opts.height,
|
||||||
|
\ })
|
||||||
|
endfunction
|
||||||
endif
|
endif
|
||||||
|
|
||||||
function! s:popup(opts) abort
|
function! s:popup_bounds() abort
|
||||||
let xoffset = get(a:opts, 'xoffset', 0.5)
|
let opts = s:popup_opts
|
||||||
let yoffset = get(a:opts, 'yoffset', 0.5)
|
|
||||||
let relative = get(a:opts, 'relative', 0)
|
let xoffset = get(opts, 'xoffset', 0.5)
|
||||||
|
let yoffset = get(opts, 'yoffset', 0.5)
|
||||||
|
let relative = get(opts, 'relative', 0)
|
||||||
|
|
||||||
" Use current window size for positioning relatively positioned popups
|
" Use current window size for positioning relatively positioned popups
|
||||||
let columns = relative ? winwidth(0) : &columns
|
let columns = relative ? winwidth(0) : &columns
|
||||||
let lines = relative ? winheight(0) : (&lines - has('nvim'))
|
let lines = relative ? winheight(0) : (&lines - has('nvim'))
|
||||||
|
|
||||||
" Size and position
|
" Size and position
|
||||||
let width = min([max([8, a:opts.width > 1 ? a:opts.width : float2nr(columns * a:opts.width)]), columns])
|
let width = min([max([8, opts.width > 1 ? opts.width : float2nr(columns * opts.width)]), columns])
|
||||||
let height = min([max([4, a:opts.height > 1 ? a:opts.height : float2nr(lines * a:opts.height)]), lines])
|
let height = min([max([4, opts.height > 1 ? opts.height : float2nr(lines * opts.height)]), lines])
|
||||||
let row = float2nr(yoffset * (lines - height)) + (relative ? win_screenpos(0)[0] - 1 : 0)
|
let row = float2nr(yoffset * (lines - height)) + (relative ? win_screenpos(0)[0] - 1 : 0)
|
||||||
let col = float2nr(xoffset * (columns - width)) + (relative ? win_screenpos(0)[1] - 1 : 0)
|
let col = float2nr(xoffset * (columns - width)) + (relative ? win_screenpos(0)[1] - 1 : 0)
|
||||||
|
|
||||||
@@ -1082,9 +1106,17 @@ function! s:popup(opts) abort
|
|||||||
let row += !has('nvim')
|
let row += !has('nvim')
|
||||||
let col += !has('nvim')
|
let col += !has('nvim')
|
||||||
|
|
||||||
call s:create_popup({
|
return { 'row': row, 'col': col, 'width': width, 'height': height }
|
||||||
\ 'row': row, 'col': col, 'width': width, 'height': height
|
endfunction
|
||||||
\ })
|
|
||||||
|
function! s:popup(opts) abort
|
||||||
|
let s:popup_opts = a:opts
|
||||||
|
call s:create_popup()
|
||||||
|
|
||||||
|
augroup fzf_popup_resize
|
||||||
|
autocmd!
|
||||||
|
autocmd VimResized * call s:resize_popup()
|
||||||
|
augroup END
|
||||||
endfunction
|
endfunction
|
||||||
|
|
||||||
let s:default_action = {
|
let s:default_action = {
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
function __fzf_defaults
|
|
||||||
# $argv[1]: Prepend to FZF_DEFAULT_OPTS_FILE and FZF_DEFAULT_OPTS
|
|
||||||
# $argv[2..]: Append to FZF_DEFAULT_OPTS_FILE and FZF_DEFAULT_OPTS
|
|
||||||
test -n "$FZF_TMUX_HEIGHT"; or set -l FZF_TMUX_HEIGHT 40%
|
|
||||||
string join ' ' -- \
|
|
||||||
"--height $FZF_TMUX_HEIGHT --min-height=20+ --bind=ctrl-z:ignore" $argv[1] \
|
|
||||||
(test -r "$FZF_DEFAULT_OPTS_FILE"; and string join -- ' ' <$FZF_DEFAULT_OPTS_FILE) \
|
|
||||||
$FZF_DEFAULT_OPTS $argv[2..-1]
|
|
||||||
end
|
|
||||||
|
|
||||||
function __fzfcmd
|
|
||||||
test -n "$FZF_TMUX_HEIGHT"; or set -l FZF_TMUX_HEIGHT 40%
|
|
||||||
if test -n "$FZF_TMUX_OPTS"
|
|
||||||
echo "fzf-tmux $FZF_TMUX_OPTS -- "
|
|
||||||
else if test "$FZF_TMUX" = "1"
|
|
||||||
echo "fzf-tmux -d$FZF_TMUX_HEIGHT -- "
|
|
||||||
else
|
|
||||||
echo "fzf"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
function __fzf_cmd_tokens -d 'Return command line tokens, skipping leading env assignments and command prefixes'
|
|
||||||
# Get tokens - use version-appropriate flags
|
|
||||||
set -l tokens
|
|
||||||
if test (string match -r -- '^\d+' $version) -ge 4
|
|
||||||
set -- tokens (commandline -xpc)
|
|
||||||
else
|
|
||||||
set -- tokens (commandline -opc)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Filter out leading environment variable assignments
|
|
||||||
set -l -- var_count 0
|
|
||||||
for i in $tokens
|
|
||||||
if string match -qr -- '^[\w]+=' $i
|
|
||||||
set var_count (math $var_count + 1)
|
|
||||||
else
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
|
||||||
set -e -- tokens[0..$var_count]
|
|
||||||
|
|
||||||
# Skip command prefixes so callers see the actual command name,
|
|
||||||
# e.g. "builtin cd" → "cd", "env VAR=1 command cd" → "cd"
|
|
||||||
while true
|
|
||||||
switch "$tokens[1]"
|
|
||||||
case builtin command
|
|
||||||
set -e -- tokens[1]
|
|
||||||
test "$tokens[1]" = "--"; and set -e -- tokens[1]
|
|
||||||
case env
|
|
||||||
set -e -- tokens[1]
|
|
||||||
test "$tokens[1]" = "--"; and set -e -- tokens[1]
|
|
||||||
while string match -qr -- '^[\w]+=' "$tokens[1]"
|
|
||||||
set -e -- tokens[1]
|
|
||||||
end
|
|
||||||
case '*'
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
string escape -n -- $tokens
|
|
||||||
end
|
|
||||||
|
|
||||||
function __fzf_parse_commandline -d 'Parse the current command line token and return split of existing filepath, fzf query, and optional -option= prefix'
|
|
||||||
set -l fzf_query ''
|
|
||||||
set -l prefix ''
|
|
||||||
set -l dir '.'
|
|
||||||
|
|
||||||
# Set variables containing the major and minor fish version numbers, using
|
|
||||||
# a method compatible with all supported fish versions.
|
|
||||||
set -l -- fish_major (string match -r -- '^\d+' $version)
|
|
||||||
set -l -- fish_minor (string match -r -- '^\d+\.(\d+)' $version)[2]
|
|
||||||
|
|
||||||
# fish v3.3.0 and newer: Don't use option prefix if " -- " is preceded.
|
|
||||||
set -l -- match_regex '(?<fzf_query>[\s\S]*?(?=\n?$)$)'
|
|
||||||
set -l -- prefix_regex '^-[^\s=]+=|^-(?!-)\S'
|
|
||||||
if test "$fish_major" -eq 3 -a "$fish_minor" -lt 3
|
|
||||||
or string match -q -v -- '* -- *' (string sub -l (commandline -Cp) -- (commandline -p))
|
|
||||||
set -- match_regex "(?<prefix>$prefix_regex)?$match_regex"
|
|
||||||
end
|
|
||||||
|
|
||||||
# Set $prefix and expanded $fzf_query with preserved trailing newlines.
|
|
||||||
if test "$fish_major" -ge 4
|
|
||||||
# fish v4.0.0 and newer
|
|
||||||
string match -q -r -- $match_regex (commandline --current-token --tokens-expanded | string collect -N)
|
|
||||||
else if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2
|
|
||||||
# fish v3.2.0 - v3.7.1 (last v3)
|
|
||||||
string match -q -r -- $match_regex (commandline --current-token --tokenize | string collect -N)
|
|
||||||
eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r -a '^\\\(?=~)|\\\(?=\$\w)' '')
|
|
||||||
else
|
|
||||||
# fish older than v3.2.0 (v3.1b1 - v3.1.2)
|
|
||||||
set -l -- cl_token (commandline --current-token --tokenize | string collect -N)
|
|
||||||
set -- prefix (string match -r -- $prefix_regex $cl_token)
|
|
||||||
set -- fzf_query (string replace -- "$prefix" '' $cl_token | string collect -N)
|
|
||||||
eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r -a '^\\\(?=~)|\\\(?=\$\w)|\\\n\\\n$' '')
|
|
||||||
end
|
|
||||||
|
|
||||||
if test -n "$fzf_query"
|
|
||||||
# Normalize path in $fzf_query, set $dir to the longest existing directory.
|
|
||||||
if test \( "$fish_major" -ge 4 \) -o \( "$fish_major" -eq 3 -a "$fish_minor" -ge 5 \)
|
|
||||||
# fish v3.5.0 and newer
|
|
||||||
set -- fzf_query (path normalize -- $fzf_query)
|
|
||||||
set -- dir $fzf_query
|
|
||||||
while not path is -d $dir
|
|
||||||
set -- dir (path dirname $dir)
|
|
||||||
end
|
|
||||||
else
|
|
||||||
# fish older than v3.5.0 (v3.1b1 - v3.4.1)
|
|
||||||
if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2
|
|
||||||
# fish v3.2.0 - v3.4.1
|
|
||||||
string match -q -r -- '(?<fzf_query>^[\s\S]*?(?=\n?$)$)' \
|
|
||||||
(string replace -r -a -- '(?<=/)/|(?<!^)/+(?!\n)$' '' $fzf_query | string collect -N)
|
|
||||||
else
|
|
||||||
# fish v3.1b1 - v3.1.2
|
|
||||||
set -- fzf_query (string replace -r -a -- '(?<=/)/|(?<!^)/+(?!\n)$' '' $fzf_query | string collect -N)
|
|
||||||
eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r '\\\n$' '')
|
|
||||||
end
|
|
||||||
set -- dir $fzf_query
|
|
||||||
while not test -d "$dir"
|
|
||||||
set -- dir (dirname -z -- "$dir" | string split0)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
if not string match -q -- '.' $dir; or string match -q -r -- '^\./|^\.$' $fzf_query
|
|
||||||
# Strip $dir from $fzf_query - preserve trailing newlines.
|
|
||||||
if test "$fish_major" -ge 4
|
|
||||||
# fish v4.0.0 and newer
|
|
||||||
string match -q -r -- '^'(string escape --style=regex -- $dir)'/?(?<fzf_query>[\s\S]*)' $fzf_query
|
|
||||||
else if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2
|
|
||||||
# fish v3.2.0 - v3.7.1 (last v3)
|
|
||||||
string match -q -r -- '^/?(?<fzf_query>[\s\S]*?(?=\n?$)$)' \
|
|
||||||
(string replace -- "$dir" '' $fzf_query | string collect -N)
|
|
||||||
else
|
|
||||||
# fish older than v3.2.0 (v3.1b1 - v3.1.2)
|
|
||||||
set -- fzf_query (string replace -- "$dir" '' $fzf_query | string collect -N)
|
|
||||||
eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r -a '^/?|\\\n$' '')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
string escape -n -- "$dir" "$fzf_query" "$prefix"
|
|
||||||
end
|
|
||||||
+2
-2
@@ -1,9 +1,9 @@
|
|||||||
__fzf_defaults() {
|
__fzf_defaults() {
|
||||||
# $1: Prepend to FZF_DEFAULT_OPTS_FILE and FZF_DEFAULT_OPTS
|
# $1: Prepend to FZF_DEFAULT_OPTS_FILE and FZF_DEFAULT_OPTS
|
||||||
# $2: Append to FZF_DEFAULT_OPTS_FILE and FZF_DEFAULT_OPTS
|
# $2: Append to FZF_DEFAULT_OPTS_FILE and FZF_DEFAULT_OPTS
|
||||||
printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
|
builtin printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
|
||||||
command cat "${FZF_DEFAULT_OPTS_FILE-}" 2> /dev/null
|
command cat "${FZF_DEFAULT_OPTS_FILE-}" 2> /dev/null
|
||||||
printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
|
builtin printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
|
||||||
}
|
}
|
||||||
|
|
||||||
__fzf_exec_awk() {
|
__fzf_exec_awk() {
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# ____ ____
|
||||||
|
# / __/___ / __/
|
||||||
|
# / /_/_ / / /_
|
||||||
|
# / __/ / /_/ __/
|
||||||
|
# /_/ /___/_/ completion-examples.nu
|
||||||
|
#
|
||||||
|
# Example custom completers for fzf's Nushell integration.
|
||||||
|
#
|
||||||
|
# To use these, add the desired entries to $env.FZF_COMPLETERS in your
|
||||||
|
# config.nu. Each closure receives two arguments:
|
||||||
|
# - prefix: the text before the trigger (e.g. "vim" in "vim **<TAB>")
|
||||||
|
# - spans: the full command as a list of words (e.g. ["pacman", "-S", "vim**"])
|
||||||
|
#
|
||||||
|
# A closure can return either:
|
||||||
|
# - a list of candidate strings (fzf will use default options), or
|
||||||
|
# - a record with the following optional fields:
|
||||||
|
# candidates: list<string> # candidates to feed to fzf
|
||||||
|
# opts: list<string> # custom fzf options (default: ["-m"])
|
||||||
|
# post: closure (|sel| ...) # post-processing of the selected item
|
||||||
|
#
|
||||||
|
# Simple example:
|
||||||
|
# $env.FZF_COMPLETERS = {
|
||||||
|
# git: {|prefix, spans| ["branch-main", "branch-dev", "branch-feature"]}
|
||||||
|
# }
|
||||||
|
|
||||||
|
# --- pacman / paru ---
|
||||||
|
# Completes package names for pacman and paru.
|
||||||
|
# Uses the spans to distinguish between subcommands:
|
||||||
|
# -S (sync), -F (files): list available packages from repos
|
||||||
|
# -Q (query), -R (remove): list installed packages
|
||||||
|
# Returns a record with custom fzf options for package preview.
|
||||||
|
|
||||||
|
$env.FZF_COMPLETERS = {}
|
||||||
|
|
||||||
|
$env.FZF_COMPLETERS.pacman = {|prefix, spans|
|
||||||
|
let sub = $spans | skip 1 | first
|
||||||
|
let candidates = (if ($sub =~ "-[SF]") {
|
||||||
|
^pacman -Slq | lines
|
||||||
|
} else if ($sub =~ "-[QR]") {
|
||||||
|
^pacman -Qq | lines
|
||||||
|
} else {
|
||||||
|
[]
|
||||||
|
})
|
||||||
|
{
|
||||||
|
candidates: $candidates
|
||||||
|
opts: ["-m", "--preview", "pacman -Si {}", "--prompt", "Package > "]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$env.FZF_COMPLETERS.paru = {|prefix, spans|
|
||||||
|
let sub = $spans | skip 1 | first
|
||||||
|
let candidates = (if ($sub =~ "-[SF]") {
|
||||||
|
^pacman -Slq | lines
|
||||||
|
} else if ($sub =~ "-[QR]") {
|
||||||
|
^pacman -Qq | lines
|
||||||
|
} else {
|
||||||
|
[]
|
||||||
|
})
|
||||||
|
{
|
||||||
|
candidates: $candidates
|
||||||
|
opts: ["-m", "--preview", "pacman -Si {}", "--prompt", "Package > "]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- pass (password-store) ---
|
||||||
|
# Completes entry names from ~/.password-store.
|
||||||
|
# Returns a simple list (no custom fzf options needed).
|
||||||
|
|
||||||
|
$env.FZF_COMPLETERS.pass = {|prefix, spans|
|
||||||
|
try {
|
||||||
|
ls ~/.password-store/**/*.gpg
|
||||||
|
| get name
|
||||||
|
| each {$in | str replace -r '^.*?\.password-store/(.*).gpg' '${1}'}
|
||||||
|
} catch {
|
||||||
|
[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Example with post-processing hook ---
|
||||||
|
# The "post" closure transforms the selected line after fzf returns.
|
||||||
|
# This is useful when the displayed line contains more information than
|
||||||
|
# what you want inserted on the command line (e.g. extracting a PID from
|
||||||
|
# a full "ps" output line).
|
||||||
|
#
|
||||||
|
# $env.FZF_COMPLETERS.mycommand = {|prefix, spans|
|
||||||
|
# {
|
||||||
|
# candidates: (^some-command | lines)
|
||||||
|
# opts: ["+m", "--header-lines=1"]
|
||||||
|
# post: {|selection| $selection | split row ' ' | get 0}
|
||||||
|
# }
|
||||||
|
# }
|
||||||
+11
-12
@@ -4,8 +4,6 @@
|
|||||||
# / __/ / /_/ __/
|
# / __/ / /_/ __/
|
||||||
# /_/ /___/_/ completion.bash
|
# /_/ /___/_/ completion.bash
|
||||||
#
|
#
|
||||||
# - $FZF_TMUX (default: 0)
|
|
||||||
# - $FZF_TMUX_OPTS (default: empty)
|
|
||||||
# - $FZF_COMPLETION_TRIGGER (default: '**')
|
# - $FZF_COMPLETION_TRIGGER (default: '**')
|
||||||
# - $FZF_COMPLETION_OPTS (default: empty)
|
# - $FZF_COMPLETION_OPTS (default: empty)
|
||||||
# - $FZF_COMPLETION_PATH_OPTS (default: empty)
|
# - $FZF_COMPLETION_PATH_OPTS (default: empty)
|
||||||
@@ -38,9 +36,9 @@ if [[ $- =~ i ]]; then
|
|||||||
# the changes. See code comments in "common.sh" for the implementation details.
|
# the changes. See code comments in "common.sh" for the implementation details.
|
||||||
|
|
||||||
__fzf_defaults() {
|
__fzf_defaults() {
|
||||||
printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
|
builtin printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
|
||||||
command cat "${FZF_DEFAULT_OPTS_FILE-}" 2> /dev/null
|
command cat "${FZF_DEFAULT_OPTS_FILE-}" 2> /dev/null
|
||||||
printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
|
builtin printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
|
||||||
}
|
}
|
||||||
|
|
||||||
__fzf_exec_awk() {
|
__fzf_exec_awk() {
|
||||||
@@ -81,7 +79,7 @@ __fzf_orig_completion() {
|
|||||||
f="${BASH_REMATCH[2]}"
|
f="${BASH_REMATCH[2]}"
|
||||||
cmd="${BASH_REMATCH[3]}"
|
cmd="${BASH_REMATCH[3]}"
|
||||||
[[ $f == _fzf_* ]] && continue
|
[[ $f == _fzf_* ]] && continue
|
||||||
printf -v "_fzf_orig_completion_${cmd//[^A-Za-z0-9_]/_}" "%s" "${comp} %s ${cmd} #${f}"
|
builtin printf -v "_fzf_orig_completion_${cmd//[^A-Za-z0-9_]/_}" "%s" "${comp} %s ${cmd} #${f}"
|
||||||
if [[ $l == *" -o nospace "* ]] && [[ ${__fzf_nospace_commands-} != *" $cmd "* ]]; then
|
if [[ $l == *" -o nospace "* ]] && [[ ${__fzf_nospace_commands-} != *" $cmd "* ]]; then
|
||||||
__fzf_nospace_commands="${__fzf_nospace_commands-} $cmd "
|
__fzf_nospace_commands="${__fzf_nospace_commands-} $cmd "
|
||||||
fi
|
fi
|
||||||
@@ -111,7 +109,7 @@ __fzf_orig_completion_instantiate() {
|
|||||||
orig="${!orig_var-}"
|
orig="${!orig_var-}"
|
||||||
orig="${orig%#*}"
|
orig="${orig%#*}"
|
||||||
[[ $orig == *' %s '* ]] || return 1
|
[[ $orig == *' %s '* ]] || return 1
|
||||||
printf -v REPLY "$orig" "$func"
|
builtin printf -v REPLY "$orig" "$func"
|
||||||
}
|
}
|
||||||
|
|
||||||
_fzf_opts_completion() {
|
_fzf_opts_completion() {
|
||||||
@@ -161,6 +159,7 @@ _fzf_opts_completion() {
|
|||||||
--history
|
--history
|
||||||
--history-size
|
--history-size
|
||||||
--hscroll-off
|
--hscroll-off
|
||||||
|
--id-nth
|
||||||
--info
|
--info
|
||||||
--info-command
|
--info-command
|
||||||
--input-border
|
--input-border
|
||||||
@@ -376,7 +375,7 @@ __fzf_generic_path_completion() {
|
|||||||
eval "rest=(${FZF_COMPLETION_PATH_OPTS-})"
|
eval "rest=(${FZF_COMPLETION_PATH_OPTS-})"
|
||||||
fi
|
fi
|
||||||
if declare -F "$1" > /dev/null; then
|
if declare -F "$1" > /dev/null; then
|
||||||
eval "$1 $(printf %q "$dir")" | __fzf_comprun "$4" -q "$leftover" "${rest[@]}"
|
eval "$1 $(builtin printf %q "$dir")" | __fzf_comprun "$4" -q "$leftover" "${rest[@]}"
|
||||||
else
|
else
|
||||||
if [[ $1 =~ dir ]]; then
|
if [[ $1 =~ dir ]]; then
|
||||||
walker=dir,follow
|
walker=dir,follow
|
||||||
@@ -385,7 +384,7 @@ __fzf_generic_path_completion() {
|
|||||||
fi
|
fi
|
||||||
__fzf_comprun "$4" -q "$leftover" --walker "$walker" --walker-root="$dir" "${rest[@]}"
|
__fzf_comprun "$4" -q "$leftover" --walker "$walker" --walker-root="$dir" "${rest[@]}"
|
||||||
fi | while read -r item; do
|
fi | while read -r item; do
|
||||||
printf "%q " "${item%$3}$3"
|
builtin printf "%q " "${item%$3}$3"
|
||||||
done
|
done
|
||||||
)
|
)
|
||||||
matches=${matches% }
|
matches=${matches% }
|
||||||
@@ -395,9 +394,9 @@ __fzf_generic_path_completion() {
|
|||||||
else
|
else
|
||||||
COMPREPLY=("$cur")
|
COMPREPLY=("$cur")
|
||||||
fi
|
fi
|
||||||
# To redraw line after fzf closes (printf '\e[5n')
|
# To redraw line after fzf closes (builtin printf '\e[5n')
|
||||||
bind '"\e[0n": redraw-current-line' 2> /dev/null
|
bind '"\e[0n": redraw-current-line' 2> /dev/null
|
||||||
printf '\e[5n'
|
builtin printf '\e[5n'
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
dir=$(command dirname "$dir")
|
dir=$(command dirname "$dir")
|
||||||
@@ -455,7 +454,7 @@ _fzf_complete() {
|
|||||||
COMPREPLY=("$cur")
|
COMPREPLY=("$cur")
|
||||||
fi
|
fi
|
||||||
bind '"\e[0n": redraw-current-line' 2> /dev/null
|
bind '"\e[0n": redraw-current-line' 2> /dev/null
|
||||||
printf '\e[5n'
|
builtin printf '\e[5n'
|
||||||
return 0
|
return 0
|
||||||
else
|
else
|
||||||
_fzf_handle_dynamic_completion "$cmd" "${rest[@]}"
|
_fzf_handle_dynamic_completion "$cmd" "${rest[@]}"
|
||||||
@@ -527,7 +526,7 @@ _fzf_proc_completion_post() {
|
|||||||
# # Set the local attribute for any non-local variable that is set by _known_hosts_real()
|
# # Set the local attribute for any non-local variable that is set by _known_hosts_real()
|
||||||
# local COMPREPLY=()
|
# local COMPREPLY=()
|
||||||
# _known_hosts_real ''
|
# _known_hosts_real ''
|
||||||
# printf '%s\n' "${COMPREPLY[@]}" | command sort -u --version-sort
|
# builtin printf '%s\n' "${COMPREPLY[@]}" | command sort -u --version-sort
|
||||||
# }
|
# }
|
||||||
if ! declare -F __fzf_list_hosts > /dev/null; then
|
if ! declare -F __fzf_list_hosts > /dev/null; then
|
||||||
__fzf_list_hosts() {
|
__fzf_list_hosts() {
|
||||||
|
|||||||
+160
-232
@@ -4,238 +4,166 @@
|
|||||||
# / __/ / /_/ __/
|
# / __/ / /_/ __/
|
||||||
# /_/ /___/_/ completion.fish
|
# /_/ /___/_/ completion.fish
|
||||||
#
|
#
|
||||||
# - $FZF_COMPLETION_OPTS (default: empty)
|
# - $FZF_COMPLETION_OPTS
|
||||||
|
# - $FZF_EXPANSION_OPTS
|
||||||
|
|
||||||
function fzf_completion_setup
|
# The oldest supported fish version is 3.4.0. For this message being able to be
|
||||||
|
# displayed on older versions, the command substitution syntax $() should not
|
||||||
#----BEGIN INCLUDE common.fish
|
# be used anywhere in the script, otherwise the source command will fail.
|
||||||
# NOTE: Do not directly edit this section, which is copied from "common.fish".
|
if string match -qr -- '^[12]\\.|^3\\.[0-3]' $version
|
||||||
# To modify it, one can edit "common.fish" and run "./update.sh" to apply
|
echo "fzf completion script requires fish version 3.4.0 or newer." >&2
|
||||||
# the changes. See code comments in "common.fish" for the implementation details.
|
return 1
|
||||||
|
else if not command -q fzf
|
||||||
function __fzf_defaults
|
echo "fzf was not found in path." >&2
|
||||||
test -n "$FZF_TMUX_HEIGHT"; or set -l FZF_TMUX_HEIGHT 40%
|
return 1
|
||||||
string join ' ' -- \
|
|
||||||
"--height $FZF_TMUX_HEIGHT --min-height=20+ --bind=ctrl-z:ignore" $argv[1] \
|
|
||||||
(test -r "$FZF_DEFAULT_OPTS_FILE"; and string join -- ' ' <$FZF_DEFAULT_OPTS_FILE) \
|
|
||||||
$FZF_DEFAULT_OPTS $argv[2..-1]
|
|
||||||
end
|
|
||||||
|
|
||||||
function __fzfcmd
|
|
||||||
test -n "$FZF_TMUX_HEIGHT"; or set -l FZF_TMUX_HEIGHT 40%
|
|
||||||
if test -n "$FZF_TMUX_OPTS"
|
|
||||||
echo "fzf-tmux $FZF_TMUX_OPTS -- "
|
|
||||||
else if test "$FZF_TMUX" = "1"
|
|
||||||
echo "fzf-tmux -d$FZF_TMUX_HEIGHT -- "
|
|
||||||
else
|
|
||||||
echo "fzf"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
function __fzf_cmd_tokens -d 'Return command line tokens, skipping leading env assignments and command prefixes'
|
|
||||||
set -l tokens
|
|
||||||
if test (string match -r -- '^\d+' $version) -ge 4
|
|
||||||
set -- tokens (commandline -xpc)
|
|
||||||
else
|
|
||||||
set -- tokens (commandline -opc)
|
|
||||||
end
|
|
||||||
|
|
||||||
set -l -- var_count 0
|
|
||||||
for i in $tokens
|
|
||||||
if string match -qr -- '^[\w]+=' $i
|
|
||||||
set var_count (math $var_count + 1)
|
|
||||||
else
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
|
||||||
set -e -- tokens[0..$var_count]
|
|
||||||
|
|
||||||
while true
|
|
||||||
switch "$tokens[1]"
|
|
||||||
case builtin command
|
|
||||||
set -e -- tokens[1]
|
|
||||||
test "$tokens[1]" = "--"; and set -e -- tokens[1]
|
|
||||||
case env
|
|
||||||
set -e -- tokens[1]
|
|
||||||
test "$tokens[1]" = "--"; and set -e -- tokens[1]
|
|
||||||
while string match -qr -- '^[\w]+=' "$tokens[1]"
|
|
||||||
set -e -- tokens[1]
|
|
||||||
end
|
|
||||||
case '*'
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
string escape -n -- $tokens
|
|
||||||
end
|
|
||||||
|
|
||||||
function __fzf_parse_commandline -d 'Parse the current command line token and return split of existing filepath, fzf query, and optional -option= prefix'
|
|
||||||
set -l fzf_query ''
|
|
||||||
set -l prefix ''
|
|
||||||
set -l dir '.'
|
|
||||||
|
|
||||||
set -l -- fish_major (string match -r -- '^\d+' $version)
|
|
||||||
set -l -- fish_minor (string match -r -- '^\d+\.(\d+)' $version)[2]
|
|
||||||
|
|
||||||
set -l -- match_regex '(?<fzf_query>[\s\S]*?(?=\n?$)$)'
|
|
||||||
set -l -- prefix_regex '^-[^\s=]+=|^-(?!-)\S'
|
|
||||||
if test "$fish_major" -eq 3 -a "$fish_minor" -lt 3
|
|
||||||
or string match -q -v -- '* -- *' (string sub -l (commandline -Cp) -- (commandline -p))
|
|
||||||
set -- match_regex "(?<prefix>$prefix_regex)?$match_regex"
|
|
||||||
end
|
|
||||||
|
|
||||||
if test "$fish_major" -ge 4
|
|
||||||
string match -q -r -- $match_regex (commandline --current-token --tokens-expanded | string collect -N)
|
|
||||||
else if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2
|
|
||||||
string match -q -r -- $match_regex (commandline --current-token --tokenize | string collect -N)
|
|
||||||
eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r -a '^\\\(?=~)|\\\(?=\$\w)' '')
|
|
||||||
else
|
|
||||||
set -l -- cl_token (commandline --current-token --tokenize | string collect -N)
|
|
||||||
set -- prefix (string match -r -- $prefix_regex $cl_token)
|
|
||||||
set -- fzf_query (string replace -- "$prefix" '' $cl_token | string collect -N)
|
|
||||||
eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r -a '^\\\(?=~)|\\\(?=\$\w)|\\\n\\\n$' '')
|
|
||||||
end
|
|
||||||
|
|
||||||
if test -n "$fzf_query"
|
|
||||||
if test \( "$fish_major" -ge 4 \) -o \( "$fish_major" -eq 3 -a "$fish_minor" -ge 5 \)
|
|
||||||
set -- fzf_query (path normalize -- $fzf_query)
|
|
||||||
set -- dir $fzf_query
|
|
||||||
while not path is -d $dir
|
|
||||||
set -- dir (path dirname $dir)
|
|
||||||
end
|
|
||||||
else
|
|
||||||
if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2
|
|
||||||
string match -q -r -- '(?<fzf_query>^[\s\S]*?(?=\n?$)$)' \
|
|
||||||
(string replace -r -a -- '(?<=/)/|(?<!^)/+(?!\n)$' '' $fzf_query | string collect -N)
|
|
||||||
else
|
|
||||||
set -- fzf_query (string replace -r -a -- '(?<=/)/|(?<!^)/+(?!\n)$' '' $fzf_query | string collect -N)
|
|
||||||
eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r '\\\n$' '')
|
|
||||||
end
|
|
||||||
set -- dir $fzf_query
|
|
||||||
while not test -d "$dir"
|
|
||||||
set -- dir (dirname -z -- "$dir" | string split0)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
if not string match -q -- '.' $dir; or string match -q -r -- '^\./|^\.$' $fzf_query
|
|
||||||
if test "$fish_major" -ge 4
|
|
||||||
string match -q -r -- '^'(string escape --style=regex -- $dir)'/?(?<fzf_query>[\s\S]*)' $fzf_query
|
|
||||||
else if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2
|
|
||||||
string match -q -r -- '^/?(?<fzf_query>[\s\S]*?(?=\n?$)$)' \
|
|
||||||
(string replace -- "$dir" '' $fzf_query | string collect -N)
|
|
||||||
else
|
|
||||||
set -- fzf_query (string replace -- "$dir" '' $fzf_query | string collect -N)
|
|
||||||
eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r -a '^/?|\\\n$' '')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
string escape -n -- "$dir" "$fzf_query" "$prefix"
|
|
||||||
end
|
|
||||||
#----END INCLUDE
|
|
||||||
|
|
||||||
# Use complete builtin for specific commands
|
|
||||||
function __fzf_complete_native
|
|
||||||
set -l -- token (commandline -t)
|
|
||||||
set -l -- completions (eval complete -C \"$argv[1]\")
|
|
||||||
test -n "$completions"; or begin commandline -f repaint; return; end
|
|
||||||
|
|
||||||
# Calculate tabstop based on longest completion item (sample first 500 for performance)
|
|
||||||
set -l -- tabstop 20
|
|
||||||
set -l -- sample_size (math "min(500, "(count $completions)")")
|
|
||||||
for c in $completions[1..$sample_size]
|
|
||||||
set -l -- len (string length -V -- (string split -- \t $c))
|
|
||||||
test -n "$len[2]" -a "$len[1]" -gt "$tabstop"
|
|
||||||
and set -- tabstop $len[1]
|
|
||||||
end
|
|
||||||
# limit to 120 to prevent long lines
|
|
||||||
set -- tabstop (math "min($tabstop + 4, 120)")
|
|
||||||
|
|
||||||
set -l result
|
|
||||||
set -lx -- FZF_DEFAULT_OPTS (__fzf_defaults \
|
|
||||||
"--reverse --delimiter=\\t --nth=1 --tabstop=$tabstop --color=fg:dim,nth:regular" \
|
|
||||||
$FZF_COMPLETION_OPTS $argv[2..-1] --accept-nth=1 --read0 --print0)
|
|
||||||
set -- result (string join0 -- $completions | eval (__fzfcmd) | string split0)
|
|
||||||
and begin
|
|
||||||
set -l -- tail ' '
|
|
||||||
# Append / to bare ~username results (fish omits it unlike other shells)
|
|
||||||
set -- result (string replace -r -- '^(~\w+)\s?$' '$1/' $result)
|
|
||||||
# Don't add trailing space if single result is a directory
|
|
||||||
test (count $result) -eq 1
|
|
||||||
and string match -q -- '*/' "$result"; and set -- tail ''
|
|
||||||
|
|
||||||
set -l -- result (string escape -n -- $result)
|
|
||||||
|
|
||||||
string match -q -- '~*' "$token"
|
|
||||||
and set result (string replace -r -- '^\\\\~' '~' $result)
|
|
||||||
|
|
||||||
string match -q -- '$*' "$token"
|
|
||||||
and set result (string replace -r -- '^\\\\\$' '\$' $result)
|
|
||||||
|
|
||||||
commandline -rt -- (string join ' ' -- $result)$tail
|
|
||||||
end
|
|
||||||
commandline -f repaint
|
|
||||||
end
|
|
||||||
|
|
||||||
function _fzf_complete
|
|
||||||
set -l -- args (string escape -- $argv | string join ' ' | string split -- ' -- ')
|
|
||||||
set -l -- post_func (status function)_(string split -- ' ' $args[2])[1]_post
|
|
||||||
set -lx -- FZF_DEFAULT_OPTS (__fzf_defaults --reverse $FZF_COMPLETION_OPTS $args[1])
|
|
||||||
set -lx FZF_DEFAULT_OPTS_FILE
|
|
||||||
set -lx FZF_DEFAULT_COMMAND
|
|
||||||
set -l -- fzf_query (commandline -t | string escape)
|
|
||||||
set -l result
|
|
||||||
eval (__fzfcmd) --query=$fzf_query | while read -l r; set -a -- result $r; end
|
|
||||||
and if functions -q $post_func
|
|
||||||
commandline -rt -- (string collect -- $result | eval $post_func $args[2] | string join ' ')' '
|
|
||||||
else
|
|
||||||
commandline -rt -- (string join -- ' ' (string escape -- $result))' '
|
|
||||||
end
|
|
||||||
commandline -f repaint
|
|
||||||
end
|
|
||||||
|
|
||||||
# Kill completion (process selection)
|
|
||||||
function _fzf_complete_kill
|
|
||||||
set -l -- fzf_query (commandline -t | string escape)
|
|
||||||
set -lx -- FZF_DEFAULT_OPTS (__fzf_defaults --reverse $FZF_COMPLETION_OPTS \
|
|
||||||
--accept-nth=2 -m --header-lines=1 --no-preview --wrap)
|
|
||||||
set -lx FZF_DEFAULT_OPTS_FILE
|
|
||||||
if type -q ps
|
|
||||||
set -l -- ps_cmd 'begin command ps -eo user,pid,ppid,start,time,command 2>/dev/null;' \
|
|
||||||
'or command ps -eo user,pid,ppid,time,args 2>/dev/null;' \
|
|
||||||
'or command ps --everyone --full --windows 2>/dev/null; end'
|
|
||||||
set -l -- result (eval $ps_cmd \| (__fzfcmd) --query=$fzf_query)
|
|
||||||
and commandline -rt -- (string join ' ' -- $result)" "
|
|
||||||
else
|
|
||||||
__fzf_complete_native "kill " --multi --query=$fzf_query
|
|
||||||
end
|
|
||||||
commandline -f repaint
|
|
||||||
end
|
|
||||||
|
|
||||||
# Main completion function
|
|
||||||
function fzf-completion
|
|
||||||
set -l -- tokens (__fzf_cmd_tokens)
|
|
||||||
set -l -- current_token (commandline -t)
|
|
||||||
set -l -- cmd_name $tokens[1]
|
|
||||||
|
|
||||||
# Route to appropriate completion function
|
|
||||||
if test -n "$tokens"; and functions -q _fzf_complete_$cmd_name
|
|
||||||
_fzf_complete_$cmd_name $tokens
|
|
||||||
else
|
|
||||||
set -l -- fzf_opt --query=$current_token --multi
|
|
||||||
__fzf_complete_native "$tokens $current_token" $fzf_opt
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Bind Shift-Tab to fzf-completion (Tab retains native Fish behavior)
|
|
||||||
if test (string match -r -- '^\d+' $version) -ge 4
|
|
||||||
bind shift-tab fzf-completion
|
|
||||||
bind -M insert shift-tab fzf-completion
|
|
||||||
else
|
|
||||||
bind -k btab fzf-completion
|
|
||||||
bind -M insert -k btab fzf-completion
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Run setup
|
function fzf_complete -w fzf -d 'fzf command completion and wildcard expansion search'
|
||||||
fzf_completion_setup
|
# Restore the default shift-tab behavior on tab completions
|
||||||
|
if commandline --paging-mode
|
||||||
|
commandline -f complete-and-search
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Remove any trailing unescaped backslash from token and update command line
|
||||||
|
set -l -- token (string replace -r -- '(?<!\\\\)(?:\\\\\\\\)*\\K\\\\$' '' (commandline -t | string collect) | string collect)
|
||||||
|
commandline -rt -- $token
|
||||||
|
|
||||||
|
# Remove any line breaks from token
|
||||||
|
set -- token (string replace -ra -- '\\\\\\n' '' $token | string collect)
|
||||||
|
|
||||||
|
# regex: Match token with unescaped/unquoted glob character
|
||||||
|
set -l -- r_glob '^(?:[^\'"\\\\*]|\\\\[\\S\\s]|\'(?:\\\\[\\S\\s]|[^\'\\\\])*\'|"(?:\\\\[\\S\\s]|[^"\\\\])*")*\\*[\\S\\s]*$'
|
||||||
|
|
||||||
|
# regex: Match any unbalanced quote character
|
||||||
|
set -l -- r_quote '^(?>(?:\\\\[\\s\\S]|"(?:[^"\\\\]|\\\\[\\s\\S])*"|\'(?:[^\'\\\\]|\\\\[\\s\\S])*\'|[^\'"\\\\]+)*)\\K[\'"]'
|
||||||
|
|
||||||
|
# The expansion pattern is the token with any open quote closed, or is empty.
|
||||||
|
set -l -- glob_pattern (string match -r -- $r_glob $token | string collect)(string match -r -- $r_quote $token | string collect -a)
|
||||||
|
|
||||||
|
set -l -- cl_tokenize_opt '--tokens-expanded'
|
||||||
|
string match -q -- '3.*' $version
|
||||||
|
and set -- cl_tokenize_opt '--tokenize'
|
||||||
|
|
||||||
|
# Set command line tokens without any leading variable definitions or launcher
|
||||||
|
# commands (including their options, but not any option arguments).
|
||||||
|
set -l -- r_cmd '^(?:(?:builtin|command|doas|env|sudo|\\w+=\\S*|-\\S+)\\s+)*\\K[\\s\\S]+'
|
||||||
|
set -l -- cmd (commandline $cl_tokenize_opt --input=(commandline -pc | string match -r $r_cmd))
|
||||||
|
test -z "$token"
|
||||||
|
and set -a -- cmd ''
|
||||||
|
|
||||||
|
# Set fzf options
|
||||||
|
test -z "$FZF_TMUX_HEIGHT"
|
||||||
|
and set -l -- FZF_TMUX_HEIGHT 40%
|
||||||
|
|
||||||
|
set -lax -- FZF_DEFAULT_OPTS \
|
||||||
|
"--height=$FZF_TMUX_HEIGHT --min-height=20+ --bind=ctrl-z:ignore" \
|
||||||
|
(test -r "$FZF_DEFAULT_OPTS_FILE"; and string join -- ' ' <$FZF_DEFAULT_OPTS_FILE) \
|
||||||
|
$FZF_DEFAULT_OPTS '--bind=alt-r:toggle-raw --multi --wrap=word --reverse' \
|
||||||
|
(if test -n "$glob_pattern"; string collect -- $FZF_EXPANSION_OPTS; else;
|
||||||
|
string collect -- $FZF_COMPLETION_OPTS; end; string escape -n -- $argv) \
|
||||||
|
--with-shell=(status fish-path)\\ -c
|
||||||
|
|
||||||
|
set -lx FZF_DEFAULT_OPTS_FILE
|
||||||
|
|
||||||
|
set -l -- fzf_cmd fzf
|
||||||
|
test "$FZF_TMUX" = 1
|
||||||
|
and set -- fzf_cmd fzf-tmux $FZF_TMUX_OPTS -d$FZF_TMUX_HEIGHT --
|
||||||
|
|
||||||
|
set -l result
|
||||||
|
|
||||||
|
# Get the completion list from stdin when it's not a tty
|
||||||
|
if not isatty stdin
|
||||||
|
set -l -- custom_post_func _fzf_post_complete_$cmd[1]
|
||||||
|
functions -q $custom_post_func
|
||||||
|
or set -- custom_post_func _fzf_complete_$cmd[1]_post
|
||||||
|
|
||||||
|
if functions -q $custom_post_func
|
||||||
|
$fzf_cmd | $custom_post_func $cmd | while read -l r; set -a -- result $r; end
|
||||||
|
else if string match -q -- '*--print0*' "$FZF_DEFAULT_OPTS"
|
||||||
|
$fzf_cmd | while read -lz r; set -a -- result $r; end
|
||||||
|
else
|
||||||
|
$fzf_cmd | while read -l r; set -a -- result $r; end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Wildcard expansion
|
||||||
|
else if test -n "$glob_pattern"
|
||||||
|
# Set the command to be run by fzf, so there is a visual indicator and an
|
||||||
|
# easy way to abort on long recursive searches.
|
||||||
|
set -lx -- FZF_DEFAULT_COMMAND "for i in $glob_pattern;" \
|
||||||
|
'test -d "$i"; and string match -qv -- "*/" $i; and set -- i $i/;' \
|
||||||
|
'string join0 -- $i; end'
|
||||||
|
|
||||||
|
set -- result (string escape -n -- ($fzf_cmd --read0 --print0 --scheme=path --no-multi-line | string split0))
|
||||||
|
|
||||||
|
# Command completion
|
||||||
|
else
|
||||||
|
# Call custom function if defined
|
||||||
|
set -l -- custom_func _fzf_complete_$cmd[1]
|
||||||
|
if functions -q $custom_func; and not set -q __fzf_no_custom_complete
|
||||||
|
set -lx __fzf_no_custom_complete
|
||||||
|
$custom_func $cmd
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Workaround for complete not having newlines in results
|
||||||
|
if string match -qr -- '\\n' $token
|
||||||
|
set -- token (string replace -ra -- '(?<!\\\\)(?:\\\\\\\\)*\\K\\\\\$' '\\\\\\\\\$' $token | string collect)
|
||||||
|
set -- token (string unescape -- $token | string collect)
|
||||||
|
set -- token (string replace -ra -- '\\n' '\\\\n' $token | string collect)
|
||||||
|
end
|
||||||
|
|
||||||
|
set -- list (complete -C --escape -- (string join -- ' ' (commandline -pc $cl_tokenize_opt) $token | string collect))
|
||||||
|
if test -n "$list"
|
||||||
|
# Get the initial tabstop value
|
||||||
|
if set -l -- tabstop (string match -rga -- '--tabstop[= ](?:0*)([1-9]\\d+|[4-9])' "$FZF_DEFAULT_OPTS")[-1]
|
||||||
|
set -- tabstop (math $tabstop - 4)
|
||||||
|
else
|
||||||
|
set -- tabstop 4
|
||||||
|
end
|
||||||
|
|
||||||
|
# Determine the tabstop length for description alignment
|
||||||
|
set -l -- max_columns (math $COLUMNS - 40)
|
||||||
|
for i in $list[1..500]
|
||||||
|
set -l -- item (string split -f 1 -- \t $i)
|
||||||
|
and set -l -- len (string length -V -- $item)
|
||||||
|
and test "$len" -gt "$tabstop" -a "$len" -lt "$max_columns"
|
||||||
|
and set -- tabstop $len
|
||||||
|
end
|
||||||
|
set -- tabstop (math $tabstop + 4)
|
||||||
|
|
||||||
|
set -- result (string collect -- $list | $fzf_cmd --delimiter="\t" --tabstop=$tabstop --wrap-sign=\t"↳ " --accept-nth=1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Update command line
|
||||||
|
if test -n "$result"
|
||||||
|
# No extra space after single selection that ends with path separator
|
||||||
|
set -l -- tail ' '
|
||||||
|
test (count $result) -eq 1
|
||||||
|
and string match -q -- '*/' "$result"
|
||||||
|
and set -- tail ''
|
||||||
|
|
||||||
|
commandline -rt -- (string join -- ' ' $result)$tail
|
||||||
|
end
|
||||||
|
|
||||||
|
commandline -f repaint
|
||||||
|
end
|
||||||
|
|
||||||
|
function _fzf_complete
|
||||||
|
set -l fzf_args
|
||||||
|
for i in $argv
|
||||||
|
string match -q -- '--' $i; and break
|
||||||
|
set -a -- fzf_args $i
|
||||||
|
end
|
||||||
|
fzf_complete $fzf_args
|
||||||
|
end
|
||||||
|
|
||||||
|
# Bind to shift-tab
|
||||||
|
if string match -qr -- '^\\d\\d+|^[4-9]' $version
|
||||||
|
bind shift-tab fzf_complete
|
||||||
|
bind -M insert shift-tab fzf_complete
|
||||||
|
else
|
||||||
|
bind -k btab fzf_complete
|
||||||
|
bind -M insert -k btab fzf_complete
|
||||||
|
end
|
||||||
|
|||||||
@@ -0,0 +1,490 @@
|
|||||||
|
# ____ ____
|
||||||
|
# / __/___ / __/
|
||||||
|
# / /_/_ / / /_
|
||||||
|
# / __/ / /_/ __/
|
||||||
|
# /_/ /___/_/ completion.nu
|
||||||
|
|
||||||
|
|
||||||
|
# An implementation of completion.nu
|
||||||
|
# This loads FZF as a Nushell External Completer
|
||||||
|
# https://www.nushell.sh/cookbook/external_completers.html
|
||||||
|
|
||||||
|
|
||||||
|
# --- Default Environment Variables ---
|
||||||
|
# These can be overridden in your config.nu or environment.
|
||||||
|
# Example: $env.FZF_COMPLETION_TRIGGER = "!<TAB>"
|
||||||
|
|
||||||
|
# - $env.FZF_TMUX (default: 0)
|
||||||
|
# - $env.FZF_TMUX_OPTS (default: empty)
|
||||||
|
# - $env.FZF_TMUX_HEIGHT (default: 40%)
|
||||||
|
# - $env.FZF_COMPLETION_TRIGGER (default: '**')
|
||||||
|
# - $env.FZF_COMPLETION_OPTS (default: empty)
|
||||||
|
# - $env.FZF_COMPLETION_PATH_OPTS (default: empty)
|
||||||
|
# - $env.FZF_COMPLETION_DIR_OPTS (default: empty)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
$env.FZF_COMPLETION_TRIGGER = $env.FZF_COMPLETION_TRIGGER? | default '**'
|
||||||
|
|
||||||
|
# Options for fzf completion in general. e.g. '--border'
|
||||||
|
$env.FZF_COMPLETION_OPTS = $env.FZF_COMPLETION_OPTS? | default ''
|
||||||
|
|
||||||
|
# Options specific to path completion. e.g. '--extended'
|
||||||
|
$env.FZF_COMPLETION_PATH_OPTS = $env.FZF_COMPLETION_PATH_OPTS? | default ''
|
||||||
|
# Options specific to directory completion. e.g. '--extended'
|
||||||
|
$env.FZF_COMPLETION_DIR_OPTS = $env.FZF_COMPLETION_DIR_OPTS? | default ''
|
||||||
|
|
||||||
|
$env.FZF_COMPLETION_DIR_COMMANDS = $env.FZF_COMPLETION_DIR_COMMANDS? | default ['cd', 'pushd', 'rmdir']
|
||||||
|
|
||||||
|
# --- Helper Functions ---
|
||||||
|
|
||||||
|
# Helper to build default fzf options list
|
||||||
|
def __fzf_defaults_completion [prepend: string, append: string]: nothing -> string {
|
||||||
|
let base = $"--height ($env.FZF_TMUX_HEIGHT? | default '40%') --min-height 20+ --bind=ctrl-z:ignore ($prepend)"
|
||||||
|
let opts_file = if ($env.FZF_DEFAULT_OPTS_FILE? | default '' | is-not-empty) {
|
||||||
|
try { open --raw ($env.FZF_DEFAULT_OPTS_FILE) | str trim } catch { '' }
|
||||||
|
} else {
|
||||||
|
''
|
||||||
|
}
|
||||||
|
let default_opts = $env.FZF_DEFAULT_OPTS? | default ''
|
||||||
|
$"($base) ($opts_file) ($default_opts) ($append)" | str trim
|
||||||
|
}
|
||||||
|
|
||||||
|
# Wrapper for running fzf or fzf-tmux
|
||||||
|
def __fzf_comprun [ context_name: string # e.g., "fzf-completion" , "fzf-helper" - mainly for potential debugging
|
||||||
|
, query: string # The initial query string for fzf
|
||||||
|
, fzf_opts_arg: list<string> # Remaining options for fzf/fzf-tmux
|
||||||
|
] {
|
||||||
|
let stdin_content = try {
|
||||||
|
# Collect stdin into a single string. Adjust if structured data is expected.
|
||||||
|
$in | into string
|
||||||
|
} catch {
|
||||||
|
null # Set to null if there's no stdin or an error occurs reading it
|
||||||
|
}
|
||||||
|
|
||||||
|
let fzf_default_opts = (__fzf_defaults_completion "" ($env.FZF_COMPLETION_OPTS | default ''))
|
||||||
|
let fzf_prefinal_opt = ['--query', $query, '--reverse'] | append $fzf_opts_arg
|
||||||
|
|
||||||
|
# Get the configured height, defaulting to '40%'
|
||||||
|
let height_opt = $env.FZF_TMUX_HEIGHT? | default '40%'
|
||||||
|
|
||||||
|
# Determine if fzf should generate its own candidates via walker
|
||||||
|
let has_walker = ($fzf_prefinal_opt | find '--walker' | is-not-empty)
|
||||||
|
|
||||||
|
# Check for custom comprun function (Nu equivalent)
|
||||||
|
if (which _fzf_comprun | is-not-empty) {
|
||||||
|
# Note: Nushell doesn't have a direct equivalent to Zsh/Bash `type -t _fzf_comprun`.
|
||||||
|
# This check assumes a user might define a custom command named `_fzf_comprun`.
|
||||||
|
_fzf_comprun $context_name $query ...$fzf_prefinal_opt # Pass args correctly to custom function
|
||||||
|
} else if ($env.TMUX_PANE? | default '' | into string | is-not-empty) and (($env.FZF_TMUX? | default 0) != 0 or ($env.FZF_TMUX_OPTS? | is-not-empty)) {
|
||||||
|
# Running inside tmux, use fzf-tmux
|
||||||
|
let final_fzf_opts = if ($env.FZF_TMUX_OPTS? | is-not-empty) {
|
||||||
|
$env.FZF_TMUX_OPTS | split row ' ' | append ['--'] | append $fzf_prefinal_opt
|
||||||
|
} else {
|
||||||
|
# Use the default -d option with the configured height for fzf-tmux
|
||||||
|
['-d', $height_opt, '--'] | append $fzf_prefinal_opt
|
||||||
|
}
|
||||||
|
|
||||||
|
if $has_walker or ($stdin_content == null) {
|
||||||
|
with-env { FZF_DEFAULT_OPTS: $fzf_default_opts, FZF_DEFAULT_OPTS_FILE: '' } { fzf-tmux ...$final_fzf_opts }
|
||||||
|
} else {
|
||||||
|
$stdin_content | with-env { FZF_DEFAULT_OPTS: $fzf_default_opts, FZF_DEFAULT_OPTS_FILE: '' } { fzf-tmux ...$final_fzf_opts }
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
# Not in tmux or not configured for fzf-tmux, use fzf directly
|
||||||
|
let final_fzf_opts = $fzf_prefinal_opt
|
||||||
|
|
||||||
|
if $has_walker or ($stdin_content == null) {
|
||||||
|
with-env { FZF_DEFAULT_OPTS: $fzf_default_opts, FZF_DEFAULT_OPTS_FILE: '' } { fzf ...$final_fzf_opts }
|
||||||
|
} else {
|
||||||
|
$stdin_content | with-env { FZF_DEFAULT_OPTS: $fzf_default_opts, FZF_DEFAULT_OPTS_FILE: '' } { fzf ...$final_fzf_opts }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Generate host list for ssh/telnet
|
||||||
|
def __fzf_list_hosts [] {
|
||||||
|
# Translate the Zsh pipeline using Nu commands and external tools
|
||||||
|
let ssh_configs = try { open ~/.ssh/config | lines } catch { [] }
|
||||||
|
let ssh_configs_d = try { open ~/.ssh/config.d/* | lines } catch { [] }
|
||||||
|
let ssh_config_global = try { open /etc/ssh/ssh_config | lines } catch { [] }
|
||||||
|
let known_hosts = try { open ~/.ssh/known_hosts | lines } catch { [] }
|
||||||
|
let hosts_file = try { open /etc/hosts | lines } catch { [] }
|
||||||
|
|
||||||
|
[
|
||||||
|
(
|
||||||
|
# Process ssh config files
|
||||||
|
$ssh_configs | append $ssh_configs_d | append $ssh_config_global
|
||||||
|
# NOTE: str lowercase is new from Nushell 0.114.0. Please upgrade to Nushell >=0.114.0 if you are seeing `extra positional argument` error regarding `str lowercase` in this block.
|
||||||
|
| where {|it| ($it | str lowercase | str starts-with 'host') or ($it | str lowercase | str starts-with 'hostname') }
|
||||||
|
| parse --regex '^\s*host(?:name)?\s+(?<hosts>.+)' # Extract hosts after keyword
|
||||||
|
| default { hosts: null } # Handle lines that don't match regex
|
||||||
|
| get hosts
|
||||||
|
| where {|it| $it != null }
|
||||||
|
| split row ' '
|
||||||
|
| where {|it| not ($it =~ '[*?%]') } # Exclude patterns containing *, ?, or %
|
||||||
|
)
|
||||||
|
(
|
||||||
|
# Process known_hosts file
|
||||||
|
$known_hosts | parse --regex '^(?:\[)?(?<hosts>[a-z0-9.,:_-]+)' # Extract hostnames (possibly in [], possibly comma-separated) - added underscore
|
||||||
|
| default { hosts: null }
|
||||||
|
| get hosts
|
||||||
|
| where {|it| $it != null }
|
||||||
|
| each { |it| $it | split row ',' } # Split comma-separated hosts if any
|
||||||
|
| flatten
|
||||||
|
)
|
||||||
|
(
|
||||||
|
# Process /etc/hosts file
|
||||||
|
$hosts_file | where { |it| not ($it | str starts-with '#') } # Ignore comments
|
||||||
|
| where { |it| not ($it | str trim | is-empty) } # Ignore empty lines
|
||||||
|
| where { |it| not ($it | str contains '0.0.0.0') } # Ignore 0.0.0.0
|
||||||
|
| str replace --regex '#.*$' '' # Remove trailing comments
|
||||||
|
| parse --regex '^\s*\S+\s+(?<hosts>.+)' # Extract hosts part (after IP)
|
||||||
|
| default { hosts: null }
|
||||||
|
| get hosts
|
||||||
|
| where {|it| $it != null }
|
||||||
|
| split row ' ' # Split multiple hosts on the same line
|
||||||
|
)
|
||||||
|
]
|
||||||
|
| flatten # Combine all lists into a single stream
|
||||||
|
| where {|it| not ($it | is-empty) } # Remove empty entries
|
||||||
|
| sort | uniq # Sort and remove duplicates
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Base function for path/directory completion
|
||||||
|
def __fzf_generic_path_completion [ prefix: string # The text before the trigger
|
||||||
|
, fzf_opts_arg: list<string> # Extra options for fzf
|
||||||
|
, suffix: string # Suffix to add to selection (e.g. , "/")
|
||||||
|
] {
|
||||||
|
# --- Determine walker root and initial query from the prefix ---
|
||||||
|
|
||||||
|
mut walker_root = "."
|
||||||
|
mut initial_query = ""
|
||||||
|
|
||||||
|
if ($prefix | is-empty) {
|
||||||
|
# Case: "**"
|
||||||
|
$walker_root = "."
|
||||||
|
$initial_query = ""
|
||||||
|
} else if ($prefix | str contains (char separator)) {
|
||||||
|
# Case: "dir/subdir/partial**" or "dir/**"
|
||||||
|
$walker_root = $prefix | path dirname
|
||||||
|
$initial_query = $prefix | path basename
|
||||||
|
# Handle edge case where prefix ends with separator, e.g., "dir/"
|
||||||
|
if ($prefix | str ends-with (char separator)) {
|
||||||
|
# Remove trailing separator to get the intended directory
|
||||||
|
$walker_root = $prefix | str substring 0..-2
|
||||||
|
$initial_query = ""
|
||||||
|
}
|
||||||
|
# Ensure walker_root isn't empty if prefix was like "/file**"
|
||||||
|
# or if path dirname returned empty string for some reason (e.g. prefix="file/")
|
||||||
|
if ($walker_root | is-empty) {
|
||||||
|
if ($prefix | str starts-with (char separator)) {
|
||||||
|
$walker_root = (char separator)
|
||||||
|
} else if ($prefix | str ends-with (char separator)) {
|
||||||
|
$walker_root = $prefix | str substring 0..-2
|
||||||
|
} else { $walker_root = "." } # Fallback if dirname weirdly fails
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
# Case: "partial**" (no slashes)
|
||||||
|
$walker_root = "."
|
||||||
|
$initial_query = $prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Prepare FZF options ---
|
||||||
|
let completion_type_opts = if $suffix == '/' {
|
||||||
|
$env.FZF_COMPLETION_DIR_OPTS? | default '' | split row ' ' | where {not ($in | is-empty)}
|
||||||
|
} else {
|
||||||
|
$env.FZF_COMPLETION_PATH_OPTS? | default '' | split row ' ' | where {not ($in | is-empty)}
|
||||||
|
}
|
||||||
|
|
||||||
|
let walker_type = if ($suffix == '/') {
|
||||||
|
"dir,follow"
|
||||||
|
} else {
|
||||||
|
"file,dir,follow,hidden"
|
||||||
|
}
|
||||||
|
# Expand tilde so fzf receives a valid absolute path as walker-root
|
||||||
|
let needs_tilde_rewrite = ($walker_root | str starts-with '~')
|
||||||
|
let walker_root_expanded = ($walker_root | path expand)
|
||||||
|
|
||||||
|
# Use the 'walker_root' calculated at the beginning
|
||||||
|
let fzf_all_opts = ["--scheme=path", "--walker", $walker_type, "--walker-root", $walker_root_expanded] | append $fzf_opts_arg
|
||||||
|
| append $completion_type_opts
|
||||||
|
|
||||||
|
# Call FZF run
|
||||||
|
let fzf_selection = ( __fzf_comprun "fzf-path-completion-walker" $initial_query $fzf_all_opts ) | str trim
|
||||||
|
|
||||||
|
|
||||||
|
# --- Return Result ---
|
||||||
|
if ($fzf_selection | is-not-empty) {
|
||||||
|
# Restore tilde prefix if the user originally typed ~/
|
||||||
|
let home = $nu.home-dir | path expand
|
||||||
|
let result = if $needs_tilde_rewrite {
|
||||||
|
$fzf_selection | lines | each {|line| $line | str replace $home '~' } | str join ' '
|
||||||
|
} else {
|
||||||
|
$fzf_selection | lines | str join ' '
|
||||||
|
}
|
||||||
|
[$result]
|
||||||
|
} else {
|
||||||
|
[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Specific path completion wrapper
|
||||||
|
def _fzf_path_completion [prefix: string] {
|
||||||
|
# Zsh args: base, lbuf, _fzf_compgen_path, "-m", "", " "
|
||||||
|
# Nu: prefix, empty command name (use find), ["-m"], "", " "
|
||||||
|
__fzf_generic_path_completion $prefix ["-m"] ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# General completion helper for commands that feed a list to fzf
|
||||||
|
# This is called by ssh, kill, and user-defined completers.
|
||||||
|
def _fzf_complete [ query: string # The initial query string for fzf
|
||||||
|
, data_gen_closure: closure # Closure that generates candidates
|
||||||
|
, fzf_opts_arg: list<string> # Extra options for fzf (like -m, +m)
|
||||||
|
, --post_process_closure: closure # Closure to process the selected item (optional)
|
||||||
|
] {
|
||||||
|
# Generate candidates using the provided command
|
||||||
|
let candidates = try {
|
||||||
|
do $data_gen_closure
|
||||||
|
} catch {
|
||||||
|
# Capture the actual error object provided by the catch block
|
||||||
|
let actual_error = $in
|
||||||
|
# Print a more informative error message including the actual error details
|
||||||
|
print -e $"Error executing data_gen closure. Closure code: ($data_gen_closure). Actual error: ($actual_error)"
|
||||||
|
[]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run fzf and get selection
|
||||||
|
let fzf_selection = $candidates | to text
|
||||||
|
| __fzf_comprun "fzf-helper" $query $fzf_opts_arg
|
||||||
|
| str trim # Trim potential trailing newline from fzf
|
||||||
|
|
||||||
|
# Apply post-processing if closure provided and selection is not empty
|
||||||
|
let processed_selection = if ($fzf_selection | is-not-empty) and ($post_process_closure | is-not-empty) {
|
||||||
|
# Call the post-processing closure with the selection
|
||||||
|
try {
|
||||||
|
do $post_process_closure $fzf_selection
|
||||||
|
} catch {
|
||||||
|
print -e $"Error executing post_process closure: ($post_process_closure)"
|
||||||
|
$fzf_selection # Return original selection on error
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$fzf_selection
|
||||||
|
}
|
||||||
|
|
||||||
|
if not ($processed_selection | is-empty) {
|
||||||
|
[($processed_selection | lines | str join ' ')]
|
||||||
|
} else {
|
||||||
|
[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# SSH/Telnet completion
|
||||||
|
def _fzf_complete_ssh [ prefix: string
|
||||||
|
, input_line_before_trigger: string
|
||||||
|
] {
|
||||||
|
let words = ($input_line_before_trigger | split row ' ')
|
||||||
|
let word_count = $words | length
|
||||||
|
|
||||||
|
# Find the index of the word being completed (which is the prefix)
|
||||||
|
# If prefix is empty, completion happens after a space, index is word_count
|
||||||
|
# If prefix is not empty, it's the last word, index is word_count - 1
|
||||||
|
let completion_index = if ($prefix | is-empty) { $word_count } else { $word_count - 1 }
|
||||||
|
|
||||||
|
mut handled = false
|
||||||
|
mut completion_result = [] # List of completion strings to return
|
||||||
|
|
||||||
|
# Check for -i, -F, -E flags immediately preceding the cursor position
|
||||||
|
if $completion_index > 0 {
|
||||||
|
let prev_arg = ($words | get ($completion_index - 1))
|
||||||
|
if ($prev_arg in ['-i', '-F', '-E']) {
|
||||||
|
$handled = true
|
||||||
|
# Call path completion with the current prefix
|
||||||
|
$completion_result = (_fzf_path_completion $prefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# If not handled by path completion, do host completion
|
||||||
|
if not $handled {
|
||||||
|
let user_part = if ($prefix | str contains "@") { ($prefix | split row "@" | first) + "@" } else { "" }
|
||||||
|
# The part after '@' (or the whole prefix if no '@') is the initial query for fzf
|
||||||
|
let query = if ($prefix | str contains "@") { $prefix | split row "@" | last } else { $prefix }
|
||||||
|
|
||||||
|
let host_candidates_gen = {||
|
||||||
|
__fzf_list_hosts
|
||||||
|
| each {|host_item| $user_part + $host_item } # Prepend user@ if present in prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
# Zsh options: +m -- ; Nu: pass ["+m"]
|
||||||
|
# Pass the host part of the prefix to _fzf_complete for the initial query
|
||||||
|
let selected_host = (_fzf_complete $query $host_candidates_gen ["+m"]) # Pass host_prefix here
|
||||||
|
if not ($selected_host | is-empty) {
|
||||||
|
$completion_result = $selected_host # _fzf_complete returns a list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$completion_result
|
||||||
|
}
|
||||||
|
|
||||||
|
# Kill completion post-processor (extracts PID)
|
||||||
|
def _fzf_complete_kill_post_get_pid [selected_line: string] {
|
||||||
|
# Assuming standard ps output where PID is the second column
|
||||||
|
$selected_line | lines | each { $in | from ssv --noheaders | get 0.column1 } | to text
|
||||||
|
}
|
||||||
|
|
||||||
|
# Kill completion to get process PID
|
||||||
|
def _fzf_complete_kill [query: string] {
|
||||||
|
let ps_gen_closure = {|| # Define ps generator as a closure
|
||||||
|
# Try standard ps, then busybox, then cygwin format approximation
|
||||||
|
# Use `^ps` to ensure external command execution
|
||||||
|
try {
|
||||||
|
^ps -eo user,pid,ppid,start,time,command | complete | if $in.exit_code == 0 { $in.stdout | lines } else { error make {msg: "ps failed"} }
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
^ps -eo user,pid,ppid,time,args | complete | if $in.exit_code == 0 { $in.stdout | lines } else { error make {msg: "ps failed"} }
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
^ps --everyone --full --windows | complete | if $in.exit_code == 0 { $in.stdout | lines } else { error make {msg: "ps failed"} }
|
||||||
|
} catch {
|
||||||
|
print -e "Error: ps command failed."
|
||||||
|
[] # Return empty list on failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Note: Complex Zsh FZF bindings for kill (click-header transformer) are omitted for simplicity.
|
||||||
|
# Users can set custom bindings via FZF_DEFAULT_OPTS if needed.
|
||||||
|
let kill_post_closure = {|selected_line| _fzf_complete_kill_post_get_pid $selected_line }
|
||||||
|
|
||||||
|
let fzf_opts = ["-m", "--header-lines=1", "--no-preview", "--wrap", "--color", "fg:dim,nth:regular"]
|
||||||
|
|
||||||
|
_fzf_complete $query $ps_gen_closure $fzf_opts --post_process_closure $kill_post_closure
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Main FZF External Completer ---
|
||||||
|
|
||||||
|
# This function is registered with Nushell's external completion system.
|
||||||
|
# It gets called when Tab is pressed.
|
||||||
|
let fzf_external_completer = {|spans|
|
||||||
|
let trigger: string = $env.FZF_COMPLETION_TRIGGER? | default '**'
|
||||||
|
|
||||||
|
if ($trigger | is-empty) { return null } # Cannot work with empty trigger
|
||||||
|
if (($spans | length ) == 0) { return null } # Nothing to complete
|
||||||
|
|
||||||
|
let last_span = $spans | last
|
||||||
|
|
||||||
|
if ($last_span | str ends-with $trigger) {
|
||||||
|
# --- Trigger Found ---
|
||||||
|
|
||||||
|
# Skip sudo to determine the actual command
|
||||||
|
let cmd_spans = if ($spans | first) == "sudo" { $spans | skip 1 } else { $spans }
|
||||||
|
let cmd_word = ($cmd_spans | first | default "")
|
||||||
|
|
||||||
|
# Calculate the prefix (part before the trigger in the last span)
|
||||||
|
let prefix = $last_span | str substring 0..(-1 * ($trigger | str length) - 1)
|
||||||
|
|
||||||
|
# Reconstruct the line content *before* the trigger for context
|
||||||
|
# This is an approximation based on spans
|
||||||
|
let line_without_trigger = $cmd_spans | take (($cmd_spans | length) - 1) | append $prefix | str join ' '
|
||||||
|
|
||||||
|
# --- Dispatch to Completer ---
|
||||||
|
mut completion_results = [] # Will hold the list of strings from the completer
|
||||||
|
|
||||||
|
# Check for user-defined completer in $env.FZF_COMPLETERS first.
|
||||||
|
# Users can define custom completers in their config.nu as a record of closures:
|
||||||
|
# $env.FZF_COMPLETERS = { git: {|prefix, spans| ... }, docker: {|prefix, spans| ... } }
|
||||||
|
# Each closure receives the prefix (text before the trigger) and the full
|
||||||
|
# command spans (e.g. ["pacman", "-S", "vim**"]), and should return either:
|
||||||
|
# - a list of candidate strings, or
|
||||||
|
# - a record { candidates: [...], opts: [...], post: {|sel| ...} } to pass
|
||||||
|
# custom fzf options and/or a post-processing closure.
|
||||||
|
# See shell/completion-examples.nu for examples.
|
||||||
|
let user_completers = ($env.FZF_COMPLETERS? | default {})
|
||||||
|
if ($cmd_word in $user_completers) {
|
||||||
|
let user_gen = ($user_completers | get $cmd_word)
|
||||||
|
let user_result = (do $user_gen $prefix $cmd_spans)
|
||||||
|
if ($user_result | describe | str starts-with 'record') {
|
||||||
|
let candidates = ($user_result | get candidates)
|
||||||
|
let fzf_opts = ($user_result | get opts? | default ["-m"])
|
||||||
|
let post = ($user_result | get post? | default null)
|
||||||
|
if ($post != null) {
|
||||||
|
$completion_results = (_fzf_complete $prefix {|| $candidates} $fzf_opts --post_process_closure $post)
|
||||||
|
} else {
|
||||||
|
$completion_results = (_fzf_complete $prefix {|| $candidates} $fzf_opts)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$completion_results = (_fzf_complete $prefix {|| $user_result} ["-m"])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match $cmd_word {
|
||||||
|
"ssh" | "scp" | "sftp" | "telnet" => { $completion_results = (_fzf_complete_ssh $prefix $line_without_trigger) }
|
||||||
|
"kill" => { $completion_results = (_fzf_complete_kill $prefix) }
|
||||||
|
_ if ($cmd_word in $env.FZF_COMPLETION_DIR_COMMANDS) => {
|
||||||
|
$completion_results = (__fzf_generic_path_completion $prefix [] "/")
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
# Default to path completion if no specific command matches
|
||||||
|
$completion_results = (_fzf_path_completion $prefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Return Results ---
|
||||||
|
# The _fzf_... functions return a list of completion strings.
|
||||||
|
# Nushell's completer expects the suggestions for the token being completed (prefix + trigger).
|
||||||
|
# The results from the helper functions should be the final desired strings.
|
||||||
|
# We don't need to manually add spaces; Nushell handles that.
|
||||||
|
$completion_results # Return the list directly
|
||||||
|
} else {
|
||||||
|
# --- Trigger Not Found ---
|
||||||
|
# Return null to let Nushell fall back to other completers (e.g., default file completion).
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- WRAPPER AND REGISTRATION ---
|
||||||
|
|
||||||
|
# Guard against re-sourcing: wrapping the completer multiple times would
|
||||||
|
# nest wrappers and grow the call chain on every reload.
|
||||||
|
if ($env.__fzf_completer_registered? | default false) != true {
|
||||||
|
|
||||||
|
# Get the currently configured external completer, if any exists
|
||||||
|
let previous_external_completer = $env.config? | get completions? | get external? | get completer?
|
||||||
|
|
||||||
|
# Define the new wrapper completer
|
||||||
|
let fzf_wrapper_completer = {|spans|
|
||||||
|
# 1. Try the FZF completer logic first
|
||||||
|
let fzf_result = do $fzf_external_completer $spans
|
||||||
|
|
||||||
|
# 2. If FZF returned a result (a list, even an empty one), return it.
|
||||||
|
# `null` means FZF didn't handle it because the trigger wasn't present.
|
||||||
|
if $fzf_result != null {
|
||||||
|
$fzf_result
|
||||||
|
} else {
|
||||||
|
# 3. FZF didn't handle it, so call the previous completer (if it exists).
|
||||||
|
if $previous_external_completer != null {
|
||||||
|
do $previous_external_completer $spans
|
||||||
|
} else {
|
||||||
|
# 4. No previous completer, and FZF didn't handle it. Return null.
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Register the new wrapper completer
|
||||||
|
# This ensures external completions are enabled and sets our wrapper.
|
||||||
|
$env.config = $env.config | upsert completions {
|
||||||
|
external: {
|
||||||
|
enable: true
|
||||||
|
completer: $fzf_wrapper_completer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$env.__fzf_completer_registered = true
|
||||||
|
}
|
||||||
|
|
||||||
|
# vim: set sts=2 ts=2 sw=2 tw=120 et :
|
||||||
@@ -4,8 +4,6 @@
|
|||||||
# / __/ / /_/ __/
|
# / __/ / /_/ __/
|
||||||
# /_/ /___/_/ completion.zsh
|
# /_/ /___/_/ completion.zsh
|
||||||
#
|
#
|
||||||
# - $FZF_TMUX (default: 0)
|
|
||||||
# - $FZF_TMUX_OPTS (default: empty)
|
|
||||||
# - $FZF_COMPLETION_TRIGGER (default: '**')
|
# - $FZF_COMPLETION_TRIGGER (default: '**')
|
||||||
# - $FZF_COMPLETION_OPTS (default: empty)
|
# - $FZF_COMPLETION_OPTS (default: empty)
|
||||||
# - $FZF_COMPLETION_PATH_OPTS (default: empty)
|
# - $FZF_COMPLETION_PATH_OPTS (default: empty)
|
||||||
@@ -102,9 +100,9 @@ if [[ -o interactive ]]; then
|
|||||||
# the changes. See code comments in "common.sh" for the implementation details.
|
# the changes. See code comments in "common.sh" for the implementation details.
|
||||||
|
|
||||||
__fzf_defaults() {
|
__fzf_defaults() {
|
||||||
printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
|
builtin printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
|
||||||
command cat "${FZF_DEFAULT_OPTS_FILE-}" 2> /dev/null
|
command cat "${FZF_DEFAULT_OPTS_FILE-}" 2> /dev/null
|
||||||
printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
|
builtin printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
|
||||||
}
|
}
|
||||||
|
|
||||||
__fzf_exec_awk() {
|
__fzf_exec_awk() {
|
||||||
|
|||||||
+30
-9
@@ -4,7 +4,6 @@
|
|||||||
# / __/ / /_/ __/
|
# / __/ / /_/ __/
|
||||||
# /_/ /___/_/ key-bindings.bash
|
# /_/ /___/_/ key-bindings.bash
|
||||||
#
|
#
|
||||||
# - $FZF_TMUX_OPTS
|
|
||||||
# - $FZF_CTRL_T_COMMAND
|
# - $FZF_CTRL_T_COMMAND
|
||||||
# - $FZF_CTRL_T_OPTS
|
# - $FZF_CTRL_T_OPTS
|
||||||
# - $FZF_CTRL_R_COMMAND
|
# - $FZF_CTRL_R_COMMAND
|
||||||
@@ -25,9 +24,9 @@ if [[ $- =~ i ]]; then
|
|||||||
# the changes. See code comments in "common.sh" for the implementation details.
|
# the changes. See code comments in "common.sh" for the implementation details.
|
||||||
|
|
||||||
__fzf_defaults() {
|
__fzf_defaults() {
|
||||||
printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
|
builtin printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
|
||||||
command cat "${FZF_DEFAULT_OPTS_FILE-}" 2> /dev/null
|
command cat "${FZF_DEFAULT_OPTS_FILE-}" 2> /dev/null
|
||||||
printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
|
builtin printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
|
||||||
}
|
}
|
||||||
|
|
||||||
__fzf_exec_awk() {
|
__fzf_exec_awk() {
|
||||||
@@ -77,17 +76,35 @@ __fzf_cd__() {
|
|||||||
) && printf 'builtin cd -- %q' "$(builtin unset CDPATH && builtin cd -- "$dir" && builtin pwd)"
|
) && printf 'builtin cd -- %q' "$(builtin unset CDPATH && builtin cd -- "$dir" && builtin pwd)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
__fzf_history_delete() {
|
||||||
|
[[ -s $1 ]] || return
|
||||||
|
|
||||||
|
local offsets
|
||||||
|
offsets=($(sort -rnu "$1"))
|
||||||
|
for offset in "${offsets[@]}"; do
|
||||||
|
builtin history -d "$offset"
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ ${#offsets[@]} -gt 0 ]] && shopt -q histappend; then
|
||||||
|
builtin history -w
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
if command -v perl > /dev/null; then
|
if command -v perl > /dev/null; then
|
||||||
__fzf_history__() {
|
__fzf_history__() {
|
||||||
local output script
|
local output script deletefile
|
||||||
|
deletefile=$(mktemp)
|
||||||
script='BEGIN { getc; $/ = "\n\t"; $HISTCOUNT = $ENV{last_hist} + 1 } s/^[ *]//; s/\n/\n\t/gm; print $HISTCOUNT - $. . "\t$_" if !$seen{$_}++'
|
script='BEGIN { getc; $/ = "\n\t"; $HISTCOUNT = $ENV{last_hist} + 1 } s/^[ *]//; s/\n/\n\t/gm; print $HISTCOUNT - $. . "\t$_" if !$seen{$_}++'
|
||||||
output=$(
|
output=$(
|
||||||
set +o pipefail
|
set +o pipefail
|
||||||
builtin fc -lnr -2147483648 |
|
builtin fc -lnr -2147483648 |
|
||||||
last_hist=$(HISTTIMEFORMAT='' builtin history 1) command perl -n -l0 -e "$script" |
|
last_hist=$(HISTTIMEFORMAT='' builtin history 1) command perl -n -l0 -e "$script" |
|
||||||
FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '"$'\t'"↳ ' --highlight-line ${FZF_CTRL_R_OPTS-} +m --read0") \
|
FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '"$'\t'"↳ ' --highlight-line --bind 'shift-delete:execute-silent(cat {+f1} >> \"$deletefile\")+exclude-multi' --multi ${FZF_CTRL_R_OPTS-} --read0") \
|
||||||
FZF_DEFAULT_OPTS_FILE='' $(__fzfcmd) --query "$READLINE_LINE"
|
FZF_DEFAULT_OPTS_FILE='' $(__fzfcmd) --query "$READLINE_LINE"
|
||||||
) || return
|
)
|
||||||
|
__fzf_history_delete "$deletefile"
|
||||||
|
command rm -f "$deletefile"
|
||||||
|
[[ -n $output ]] || return
|
||||||
READLINE_LINE=$(command perl -pe 's/^\d*\t//' <<< "$output")
|
READLINE_LINE=$(command perl -pe 's/^\d*\t//' <<< "$output")
|
||||||
if [[ -z $READLINE_POINT ]]; then
|
if [[ -z $READLINE_POINT ]]; then
|
||||||
echo "$READLINE_LINE"
|
echo "$READLINE_LINE"
|
||||||
@@ -97,7 +114,8 @@ if command -v perl > /dev/null; then
|
|||||||
}
|
}
|
||||||
else # awk - fallback for POSIX systems
|
else # awk - fallback for POSIX systems
|
||||||
__fzf_history__() {
|
__fzf_history__() {
|
||||||
local output script
|
local output script deletefile
|
||||||
|
deletefile=$(mktemp)
|
||||||
[[ $(HISTTIMEFORMAT='' builtin history 1) =~ [[:digit:]]+ ]] # how many history entries
|
[[ $(HISTTIMEFORMAT='' builtin history 1) =~ [[:digit:]]+ ]] # how many history entries
|
||||||
script='function P(b) { ++n; sub(/^[ *]/, "", b); if (!seen[b]++) { printf "%d\t%s%c", '$((BASH_REMATCH + 1))' - n, b, 0 } }
|
script='function P(b) { ++n; sub(/^[ *]/, "", b); if (!seen[b]++) { printf "%d\t%s%c", '$((BASH_REMATCH + 1))' - n, b, 0 } }
|
||||||
NR==1 { b = substr($0, 2); next }
|
NR==1 { b = substr($0, 2); next }
|
||||||
@@ -108,9 +126,12 @@ else # awk - fallback for POSIX systems
|
|||||||
set +o pipefail
|
set +o pipefail
|
||||||
builtin fc -lnr -2147483648 2> /dev/null | # ( $'\t '<lines>$'\n' )* ; <lines> ::= [^\n]* ( $'\n'<lines> )*
|
builtin fc -lnr -2147483648 2> /dev/null | # ( $'\t '<lines>$'\n' )* ; <lines> ::= [^\n]* ( $'\n'<lines> )*
|
||||||
__fzf_exec_awk "$script" | # ( <counter>$'\t'<lines>$'\000' )*
|
__fzf_exec_awk "$script" | # ( <counter>$'\t'<lines>$'\000' )*
|
||||||
FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '"$'\t'"↳ ' --highlight-line ${FZF_CTRL_R_OPTS-} +m --read0") \
|
FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '"$'\t'"↳ ' --highlight-line --bind 'shift-delete:execute-silent(cat {+f1} >> \"$deletefile\")+exclude-multi' --multi ${FZF_CTRL_R_OPTS-} --read0") \
|
||||||
FZF_DEFAULT_OPTS_FILE='' $(__fzfcmd) --query "$READLINE_LINE"
|
FZF_DEFAULT_OPTS_FILE='' $(__fzfcmd) --query "$READLINE_LINE"
|
||||||
) || return
|
)
|
||||||
|
__fzf_history_delete "$deletefile"
|
||||||
|
command rm -f "$deletefile"
|
||||||
|
[[ -n $output ]] || return
|
||||||
READLINE_LINE=${output#*$'\t'}
|
READLINE_LINE=${output#*$'\t'}
|
||||||
if [[ -z $READLINE_POINT ]]; then
|
if [[ -z $READLINE_POINT ]]; then
|
||||||
echo "$READLINE_LINE"
|
echo "$READLINE_LINE"
|
||||||
|
|||||||
+48
-118
@@ -4,7 +4,6 @@
|
|||||||
# / __/ / /_/ __/
|
# / __/ / /_/ __/
|
||||||
# /_/ /___/_/ key-bindings.fish
|
# /_/ /___/_/ key-bindings.fish
|
||||||
#
|
#
|
||||||
# - $FZF_TMUX_OPTS
|
|
||||||
# - $FZF_CTRL_T_COMMAND
|
# - $FZF_CTRL_T_COMMAND
|
||||||
# - $FZF_CTRL_T_OPTS
|
# - $FZF_CTRL_T_OPTS
|
||||||
# - $FZF_CTRL_R_COMMAND
|
# - $FZF_CTRL_R_COMMAND
|
||||||
@@ -12,35 +11,29 @@
|
|||||||
# - $FZF_ALT_C_COMMAND
|
# - $FZF_ALT_C_COMMAND
|
||||||
# - $FZF_ALT_C_OPTS
|
# - $FZF_ALT_C_OPTS
|
||||||
|
|
||||||
|
|
||||||
# Key bindings
|
# Key bindings
|
||||||
# ------------
|
# ------------
|
||||||
# The oldest supported fish version is 3.1b1. To maintain compatibility, the
|
|
||||||
# command substitution syntax $(cmd) should never be used, even behind a version
|
|
||||||
# check, otherwise the source command will fail on fish versions older than 3.4.0.
|
|
||||||
function fzf_key_bindings
|
function fzf_key_bindings
|
||||||
|
|
||||||
# Check fish version
|
# The oldest supported fish version is 3.4.0. For this message being able to be
|
||||||
if set -l -- fish_ver (string match -r '^(\d+)\.(\d+)' $version 2>/dev/null)
|
# displayed on older versions, the command substitution syntax $() should not
|
||||||
and test "$fish_ver[2]" -lt 3 -o "$fish_ver[2]" -eq 3 -a "$fish_ver[3]" -lt 1
|
# be used anywhere in the script, otherwise the source command will fail.
|
||||||
echo "This script requires fish version 3.1b1 or newer." >&2
|
if string match -qr -- '^[12]\\.|^3\\.[0-3]' $version
|
||||||
|
echo "fzf key bindings script requires fish version 3.4.0 or newer." >&2
|
||||||
return 1
|
return 1
|
||||||
else if not type -q fzf
|
else if not command -q fzf
|
||||||
echo "fzf was not found in path." >&2
|
echo "fzf was not found in path." >&2
|
||||||
return 1
|
return 1
|
||||||
end
|
end
|
||||||
|
|
||||||
#----BEGIN INCLUDE common.fish
|
|
||||||
# NOTE: Do not directly edit this section, which is copied from "common.fish".
|
|
||||||
# To modify it, one can edit "common.fish" and run "./update.sh" to apply
|
|
||||||
# the changes. See code comments in "common.fish" for the implementation details.
|
|
||||||
|
|
||||||
function __fzf_defaults
|
function __fzf_defaults
|
||||||
|
# $argv[1]: Prepend to FZF_DEFAULT_OPTS_FILE and FZF_DEFAULT_OPTS
|
||||||
|
# $argv[2..]: Append to FZF_DEFAULT_OPTS_FILE and FZF_DEFAULT_OPTS
|
||||||
test -n "$FZF_TMUX_HEIGHT"; or set -l FZF_TMUX_HEIGHT 40%
|
test -n "$FZF_TMUX_HEIGHT"; or set -l FZF_TMUX_HEIGHT 40%
|
||||||
string join ' ' -- \
|
string join ' ' -- \
|
||||||
"--height $FZF_TMUX_HEIGHT --min-height=20+ --bind=ctrl-z:ignore" $argv[1] \
|
"--height $FZF_TMUX_HEIGHT --min-height=20+ --bind=ctrl-z:ignore" $argv[1] \
|
||||||
(test -r "$FZF_DEFAULT_OPTS_FILE"; and string join -- ' ' <$FZF_DEFAULT_OPTS_FILE) \
|
(test -r "$FZF_DEFAULT_OPTS_FILE"; and string join -- ' ' <$FZF_DEFAULT_OPTS_FILE) \
|
||||||
$FZF_DEFAULT_OPTS $argv[2..-1]
|
$FZF_DEFAULT_OPTS $argv[2..]
|
||||||
end
|
end
|
||||||
|
|
||||||
function __fzfcmd
|
function __fzfcmd
|
||||||
@@ -54,107 +47,59 @@ function fzf_key_bindings
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function __fzf_cmd_tokens -d 'Return command line tokens, skipping leading env assignments and command prefixes'
|
|
||||||
set -l tokens
|
|
||||||
if test (string match -r -- '^\d+' $version) -ge 4
|
|
||||||
set -- tokens (commandline -xpc)
|
|
||||||
else
|
|
||||||
set -- tokens (commandline -opc)
|
|
||||||
end
|
|
||||||
|
|
||||||
set -l -- var_count 0
|
|
||||||
for i in $tokens
|
|
||||||
if string match -qr -- '^[\w]+=' $i
|
|
||||||
set var_count (math $var_count + 1)
|
|
||||||
else
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
|
||||||
set -e -- tokens[0..$var_count]
|
|
||||||
|
|
||||||
while true
|
|
||||||
switch "$tokens[1]"
|
|
||||||
case builtin command
|
|
||||||
set -e -- tokens[1]
|
|
||||||
test "$tokens[1]" = "--"; and set -e -- tokens[1]
|
|
||||||
case env
|
|
||||||
set -e -- tokens[1]
|
|
||||||
test "$tokens[1]" = "--"; and set -e -- tokens[1]
|
|
||||||
while string match -qr -- '^[\w]+=' "$tokens[1]"
|
|
||||||
set -e -- tokens[1]
|
|
||||||
end
|
|
||||||
case '*'
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
string escape -n -- $tokens
|
|
||||||
end
|
|
||||||
|
|
||||||
function __fzf_parse_commandline -d 'Parse the current command line token and return split of existing filepath, fzf query, and optional -option= prefix'
|
function __fzf_parse_commandline -d 'Parse the current command line token and return split of existing filepath, fzf query, and optional -option= prefix'
|
||||||
set -l fzf_query ''
|
set -l fzf_query ''
|
||||||
set -l prefix ''
|
set -l prefix ''
|
||||||
set -l dir '.'
|
set -l dir '.'
|
||||||
|
|
||||||
set -l -- fish_major (string match -r -- '^\d+' $version)
|
set -l -- match_regex '(?<fzf_query>[\\s\\S]*?(?=\\n?$)$)'
|
||||||
set -l -- fish_minor (string match -r -- '^\d+\.(\d+)' $version)[2]
|
set -l -- prefix_regex '^-[^\\s=]+=|^-(?!-)\\S'
|
||||||
|
|
||||||
set -l -- match_regex '(?<fzf_query>[\s\S]*?(?=\n?$)$)'
|
# Don't use option prefix if " -- " is preceded.
|
||||||
set -l -- prefix_regex '^-[^\s=]+=|^-(?!-)\S'
|
string match -qv -- '* -- *' (string sub -l (commandline -Cp) -- (commandline -p))
|
||||||
if test "$fish_major" -eq 3 -a "$fish_minor" -lt 3
|
and set -- match_regex "(?<prefix>$prefix_regex)?$match_regex"
|
||||||
or string match -q -v -- '* -- *' (string sub -l (commandline -Cp) -- (commandline -p))
|
|
||||||
set -- match_regex "(?<prefix>$prefix_regex)?$match_regex"
|
|
||||||
end
|
|
||||||
|
|
||||||
if test "$fish_major" -ge 4
|
# Set $prefix and expanded $fzf_query with preserved trailing newlines.
|
||||||
|
if string match -qr -- '^\\d\\d+|^[4-9]' $version
|
||||||
|
# fish v4.0.0 and newer
|
||||||
string match -q -r -- $match_regex (commandline --current-token --tokens-expanded | string collect -N)
|
string match -q -r -- $match_regex (commandline --current-token --tokens-expanded | string collect -N)
|
||||||
else if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2
|
|
||||||
string match -q -r -- $match_regex (commandline --current-token --tokenize | string collect -N)
|
|
||||||
eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r -a '^\\\(?=~)|\\\(?=\$\w)' '')
|
|
||||||
else
|
else
|
||||||
set -l -- cl_token (commandline --current-token --tokenize | string collect -N)
|
string match -q -r -- $match_regex (commandline --current-token --tokenize | string collect -N)
|
||||||
set -- prefix (string match -r -- $prefix_regex $cl_token)
|
eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r -a '^\\\\(?=~)|\\\\(?=\\$\\w)' '')
|
||||||
set -- fzf_query (string replace -- "$prefix" '' $cl_token | string collect -N)
|
|
||||||
eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r -a '^\\\(?=~)|\\\(?=\$\w)|\\\n\\\n$' '')
|
|
||||||
end
|
end
|
||||||
|
|
||||||
if test -n "$fzf_query"
|
if test -n "$fzf_query"
|
||||||
if test \( "$fish_major" -ge 4 \) -o \( "$fish_major" -eq 3 -a "$fish_minor" -ge 5 \)
|
# Normalize path in $fzf_query, set $dir to the longest existing directory.
|
||||||
|
if string match -qr -- '^\\d\\d+|^4|^3\\.[5-9]' $version
|
||||||
|
# fish v3.5.0 and newer
|
||||||
set -- fzf_query (path normalize -- $fzf_query)
|
set -- fzf_query (path normalize -- $fzf_query)
|
||||||
set -- dir $fzf_query
|
set -- dir $fzf_query
|
||||||
while not path is -d $dir
|
while not path is -d $dir
|
||||||
set -- dir (path dirname $dir)
|
set -- dir (path dirname $dir)
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2
|
string match -q -r -- '(?<fzf_query>^[\\s\\S]*?(?=\\n?$)$)' \
|
||||||
string match -q -r -- '(?<fzf_query>^[\s\S]*?(?=\n?$)$)' \
|
(string replace -r -a -- '(?<=/)/|(?<!^)/+(?!\\n)$' '' $fzf_query | string collect -N)
|
||||||
(string replace -r -a -- '(?<=/)/|(?<!^)/+(?!\n)$' '' $fzf_query | string collect -N)
|
|
||||||
else
|
|
||||||
set -- fzf_query (string replace -r -a -- '(?<=/)/|(?<!^)/+(?!\n)$' '' $fzf_query | string collect -N)
|
|
||||||
eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r '\\\n$' '')
|
|
||||||
end
|
|
||||||
set -- dir $fzf_query
|
set -- dir $fzf_query
|
||||||
while not test -d "$dir"
|
while not test -d "$dir"
|
||||||
set -- dir (dirname -z -- "$dir" | string split0)
|
set -- dir (dirname -z -- "$dir" | string split0)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if not string match -q -- '.' $dir; or string match -q -r -- '^\./|^\.$' $fzf_query
|
if not string match -q -- '.' $dir; or string match -qr -- '^\\.(/|$)' $fzf_query
|
||||||
if test "$fish_major" -ge 4
|
# Strip $dir from $fzf_query - preserve trailing newlines.
|
||||||
string match -q -r -- '^'(string escape --style=regex -- $dir)'/?(?<fzf_query>[\s\S]*)' $fzf_query
|
if string match -qr -- '^\\d\\d+|^[4-9]' $version
|
||||||
else if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2
|
# fish v4.0.0 and newer
|
||||||
string match -q -r -- '^/?(?<fzf_query>[\s\S]*?(?=\n?$)$)' \
|
string match -q -r -- '^'(string escape --style=regex -- $dir)'/?(?<fzf_query>[\\s\\S]*)' $fzf_query
|
||||||
(string replace -- "$dir" '' $fzf_query | string collect -N)
|
|
||||||
else
|
else
|
||||||
set -- fzf_query (string replace -- "$dir" '' $fzf_query | string collect -N)
|
string match -q -r -- '^/?(?<fzf_query>[\\s\\S]*?(?=\\n?$)$)' \
|
||||||
eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r -a '^/?|\\\n$' '')
|
(string replace -- "$dir" '' $fzf_query | string collect -N)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
string escape -n -- "$dir" "$fzf_query" "$prefix"
|
string escape -n -- "$dir" "$fzf_query" "$prefix"
|
||||||
end
|
end
|
||||||
#----END INCLUDE
|
|
||||||
|
|
||||||
# Store current token in $dir as root for the 'find' command
|
# Store current token in $dir as root for the 'find' command
|
||||||
function fzf-file-widget -d "List files and folders"
|
function fzf-file-widget -d "List files and folders"
|
||||||
@@ -171,7 +116,7 @@ function fzf_key_bindings
|
|||||||
set -lx FZF_DEFAULT_OPTS_FILE
|
set -lx FZF_DEFAULT_OPTS_FILE
|
||||||
|
|
||||||
set -l result (eval (__fzfcmd) --walker-root=$dir --query=$fzf_query | string split0)
|
set -l result (eval (__fzfcmd) --walker-root=$dir --query=$fzf_query | string split0)
|
||||||
and commandline -rt -- (string join -- ' ' $prefix(string escape --no-quoted -- $result))' '
|
and commandline -rt -- (string join -- ' ' $prefix(string escape -n -- $result))' '
|
||||||
|
|
||||||
commandline -f repaint
|
commandline -f repaint
|
||||||
end
|
end
|
||||||
@@ -182,48 +127,33 @@ function fzf_key_bindings
|
|||||||
set -l -- total_lines (count $command_line)
|
set -l -- total_lines (count $command_line)
|
||||||
set -l -- fzf_query (string escape -- $command_line[$current_line])
|
set -l -- fzf_query (string escape -- $command_line[$current_line])
|
||||||
|
|
||||||
|
set -lx -- FZF_DEFAULT_COMMAND "builtin history -z --show-time=(set_color $fish_color_comment 2>/dev/null; or set_color normal)\"%F %a %T%t%s%t\"(set_color normal)"
|
||||||
|
|
||||||
|
# Enable syntax highlighting colors on fish v4.3.3 and newer
|
||||||
|
if string match -qr -- '^\\d\\d+|^4\\.[4-9]|^4\\.3\\.[3-9]' $version
|
||||||
|
set -a -- FZF_DEFAULT_COMMAND "--color=always"
|
||||||
|
end
|
||||||
|
|
||||||
set -lx -- FZF_DEFAULT_OPTS (__fzf_defaults '' \
|
set -lx -- FZF_DEFAULT_OPTS (__fzf_defaults '' \
|
||||||
'--nth=2..,.. --scheme=history --multi --no-multi-line --no-wrap --wrap-sign="\t\t\t↳ " --preview-wrap-sign="↳ "' \
|
'--with-nth=2.. --nth=2..,.. --scheme=history --multi --no-multi-line' \
|
||||||
'--bind=\'shift-delete:execute-silent(for i in (string split0 -- <{+f}); eval builtin history delete --exact --case-sensitive -- (string escape -n -- $i | string replace -r "^\d*\\\\\\t" ""); end)+reload(eval $FZF_DEFAULT_COMMAND)\'' \
|
'--no-wrap --wrap-sign="\t\t\t↳ " --preview-wrap-sign="↳ " --freeze-left=1' \
|
||||||
'--bind="alt-enter:become(string join0 -- (string collect -- {+2..} | fish_indent -i))"' \
|
'--bind="alt-enter:become(set -g fzf_temp {+sf3..}; string join0 -- (string split0 -- <$fzf_temp | fish_indent -i); unlink $fzf_temp &>/dev/null)"' \
|
||||||
|
'--bind="alt-t:change-with-nth(1,3..|3..|2..)"' \
|
||||||
|
"--bind='shift-delete:execute-silent(eval builtin history delete -Ce -- (string escape -n -- (string split0 -- <{+sf3..})))+reload($FZF_DEFAULT_COMMAND)'" \
|
||||||
"--bind=ctrl-r:toggle-sort,alt-r:toggle-raw --highlight-line $FZF_CTRL_R_OPTS" \
|
"--bind=ctrl-r:toggle-sort,alt-r:toggle-raw --highlight-line $FZF_CTRL_R_OPTS" \
|
||||||
'--accept-nth=2.. --delimiter="\t" --tabstop=4 --read0 --print0 --with-shell='(status fish-path)\\ -c)
|
'--accept-nth=3.. --delimiter="\t" --tabstop=4 --ansi --read0 --print0 --with-shell='(status fish-path)\\ -c)
|
||||||
|
|
||||||
# Add dynamic preview options if preview command isn't already set by user
|
# Add dynamic preview options if preview command isn't already set by user
|
||||||
if string match -qvr -- '--preview[= ]' "$FZF_DEFAULT_OPTS"
|
if string match -qvr -- '--preview[= ]' "$FZF_DEFAULT_OPTS"
|
||||||
# Convert the highlighted timestamp using the date command if available
|
# Prepend the options to allow user overrides
|
||||||
set -l -- date_cmd '{1}'
|
|
||||||
if type -q date
|
|
||||||
if date -d @0 '+%s' 2>/dev/null | string match -q 0
|
|
||||||
# GNU date
|
|
||||||
set -- date_cmd '(date -d @{1} \\"+%F %a %T\\")'
|
|
||||||
else if date -r 0 '+%s' 2>/dev/null | string match -q 0
|
|
||||||
# BSD date
|
|
||||||
set -- date_cmd '(date -r {1} \\"+%F %a %T\\")'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Prepend the options to allow user customizations
|
|
||||||
set -p -- FZF_DEFAULT_OPTS \
|
set -p -- FZF_DEFAULT_OPTS \
|
||||||
'--bind="focus,resize:bg-transform:if test \\"$FZF_COLUMNS\\" -gt 100 -a \\\\( \\"$FZF_SELECT_COUNT\\" -gt 0 -o \\\\( -z \\"$FZF_WRAP\\" -a (string length -- {}) -gt (math $FZF_COLUMNS - 4) \\\\) -o (string collect -- {2..} | fish_indent | count) -gt 1 \\\\); echo show-preview; else echo hide-preview; end"' \
|
'--bind="focus,multi,resize:bg-transform:if test \\"$FZF_COLUMNS\\" -gt 100 -a \\\\( \\"$FZF_SELECT_COUNT\\" -gt 0 -o \\\\( -z \\"$FZF_WRAP\\" -a (string join0 -- <{f3..} | string length) -gt (math $FZF_COLUMNS - (switch $FZF_WITH_NTH; case 2..; echo 13; case 1,3..; echo 25; case 3..; echo 1; end)) \\\\) -o (string split0 -- <{sf3..} | fish_indent | count) -gt 1 \\\\); echo show-preview; else; echo hide-preview; end"' \
|
||||||
'--preview="string collect -- (test \\"$FZF_SELECT_COUNT\\" -gt 0; and string collect -- {+2..}) \\"\\n# \\"'$date_cmd' {2..} | fish_indent --ansi"' \
|
'--preview="test \\"$FZF_SELECT_COUNT\\" -gt 0; and string split0 -- <{+sf3..} | fish_indent (string match -q -- 3.\\\\* $version; or echo -- --only-indent) --ansi; and echo -n \\\\n; string collect -- \\\\#\\\\ {1} (string split0 -- <{sf3..}) | fish_indent --ansi"' \
|
||||||
'--preview-window="right,50%,wrap-word,follow,info,hidden"'
|
'--preview-window="right,50%,wrap-word,follow,info,hidden"'
|
||||||
end
|
end
|
||||||
|
|
||||||
set -lx FZF_DEFAULT_OPTS_FILE
|
set -lx FZF_DEFAULT_OPTS_FILE
|
||||||
|
|
||||||
set -lx -- FZF_DEFAULT_COMMAND 'builtin history -z --show-time="%s%t"'
|
|
||||||
|
|
||||||
# Enable syntax highlighting colors on fish v4.3.3 and newer
|
|
||||||
if set -l -- v (string match -r -- '^(\d+)\.(\d+)(?:\.(\d+))?' $version)
|
|
||||||
and test "$v[2]" -gt 4 -o "$v[2]" -eq 4 -a \
|
|
||||||
\( "$v[3]" -gt 3 -o "$v[3]" -eq 3 -a \
|
|
||||||
\( -n "$v[4]" -a "$v[4]" -ge 3 \) \)
|
|
||||||
|
|
||||||
set -a -- FZF_DEFAULT_OPTS '--ansi'
|
|
||||||
set -a -- FZF_DEFAULT_COMMAND '--color=always'
|
|
||||||
end
|
|
||||||
|
|
||||||
# Merge history from other sessions before searching
|
# Merge history from other sessions before searching
|
||||||
test -z "$fish_private_mode"; and builtin history merge
|
test -z "$fish_private_mode"; and builtin history merge
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# ____ ____
|
||||||
|
# / __/___ / __/
|
||||||
|
# / /_/_ / / /_
|
||||||
|
# / __/ / /_/ __/
|
||||||
|
# /_/ /___/_/ key-bindings.nu
|
||||||
|
#
|
||||||
|
# - $FZF_TMUX (default: 0)
|
||||||
|
# - $FZF_TMUX_OPTS
|
||||||
|
# - $FZF_TMUX_HEIGHT (default: 40%)
|
||||||
|
# - $FZF_CTRL_T_COMMAND (set to "" to disable)
|
||||||
|
# - $FZF_CTRL_T_OPTS
|
||||||
|
# - $FZF_CTRL_R_COMMAND (set to "" to disable)
|
||||||
|
# - $FZF_CTRL_R_OPTS
|
||||||
|
# - $FZF_ALT_C_COMMAND (set to "" to disable)
|
||||||
|
# - $FZF_ALT_C_OPTS
|
||||||
|
|
||||||
|
# Code provided by @igor-ramazanov
|
||||||
|
# Source: https://github.com/junegunn/fzf/issues/4122#issuecomment-2607368316
|
||||||
|
|
||||||
|
# Merge default options in the same order as bash/zsh:
|
||||||
|
# 1. --height, --min-height, --bind=ctrl-z:ignore, $prepend
|
||||||
|
# 2. $FZF_DEFAULT_OPTS_FILE contents
|
||||||
|
# 3. $FZF_DEFAULT_OPTS, $append
|
||||||
|
def __fzf_defaults [prepend: string, append: string]: nothing -> string {
|
||||||
|
let base = $"--height ($env.FZF_TMUX_HEIGHT? | default '40%') --min-height 20+ --bind=ctrl-z:ignore ($prepend)"
|
||||||
|
let opts_file = if ($env.FZF_DEFAULT_OPTS_FILE? | default '' | is-not-empty) {
|
||||||
|
try { open --raw ($env.FZF_DEFAULT_OPTS_FILE) | str trim } catch { '' }
|
||||||
|
} else {
|
||||||
|
''
|
||||||
|
}
|
||||||
|
let default_opts = $env.FZF_DEFAULT_OPTS? | default ''
|
||||||
|
$"($base) ($opts_file) ($default_opts) ($append)" | str trim
|
||||||
|
}
|
||||||
|
|
||||||
|
# Return the fzf command to use: fzf-tmux when inside tmux and
|
||||||
|
# FZF_TMUX is enabled or FZF_TMUX_OPTS is set, plain fzf otherwise.
|
||||||
|
def __fzfcmd []: nothing -> list<string> {
|
||||||
|
let in_tmux = ($env.TMUX_PANE? | default '' | into string | is-not-empty)
|
||||||
|
if $in_tmux {
|
||||||
|
let fzf_tmux = ($env.FZF_TMUX? | default 0 | into string)
|
||||||
|
let fzf_tmux_opts = ($env.FZF_TMUX_OPTS? | default '' | into string)
|
||||||
|
if ($fzf_tmux != '0') or ($fzf_tmux_opts | is-not-empty) {
|
||||||
|
let opts = if ($fzf_tmux_opts | is-not-empty) { $fzf_tmux_opts } else { $"-d($env.FZF_TMUX_HEIGHT? | default '40%')" }
|
||||||
|
return ['fzf-tmux' ...(($opts | split row ' ' | where { $in != '' })) '--']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
['fzf']
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export-env {
|
||||||
|
$env.FZF_CTRL_T_OPTS = $env.FZF_CTRL_T_OPTS? | default ""
|
||||||
|
$env.FZF_CTRL_R_OPTS = $env.FZF_CTRL_R_OPTS? | default ""
|
||||||
|
$env.FZF_ALT_C_OPTS = $env.FZF_ALT_C_OPTS? | default ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Directories
|
||||||
|
const alt_c = {
|
||||||
|
name: fzf_dirs
|
||||||
|
modifier: alt
|
||||||
|
keycode: char_c
|
||||||
|
mode: [emacs, vi_normal, vi_insert]
|
||||||
|
event: [
|
||||||
|
{
|
||||||
|
send: executehostcommand
|
||||||
|
cmd: "
|
||||||
|
let fzf_opts = (__fzf_defaults '--reverse --walker=dir,follow,hidden --scheme=path' $'($env.FZF_ALT_C_OPTS) +m');
|
||||||
|
let fzfcmd = (__fzfcmd);
|
||||||
|
let fzf_args = ($fzfcmd | skip 1);
|
||||||
|
let alt_c_cmd = ($env.FZF_ALT_C_COMMAND? | default null);
|
||||||
|
let result = if ($alt_c_cmd == null) or ($alt_c_cmd | is-empty) {
|
||||||
|
with-env { FZF_DEFAULT_OPTS: $fzf_opts, FZF_DEFAULT_OPTS_FILE: '' } { ^($fzfcmd | first) ...$fzf_args }
|
||||||
|
} else {
|
||||||
|
let fzf_cmd_str = ($fzfcmd | str join ' ');
|
||||||
|
let sh_cmd = [$alt_c_cmd '|' $fzf_cmd_str] | str join ' ';
|
||||||
|
with-env { FZF_DEFAULT_OPTS: $fzf_opts, FZF_DEFAULT_OPTS_FILE: '' } { ^sh -c $sh_cmd }
|
||||||
|
};
|
||||||
|
if ($result | is-not-empty) { cd $result };
|
||||||
|
"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
# History
|
||||||
|
const ctrl_r = {
|
||||||
|
name: fzf_history
|
||||||
|
modifier: control
|
||||||
|
keycode: char_r
|
||||||
|
mode: [emacs, vi_insert, vi_normal]
|
||||||
|
event: [
|
||||||
|
{
|
||||||
|
send: executehostcommand
|
||||||
|
cmd: "commandline edit --replace (
|
||||||
|
let fzf_opts = (__fzf_defaults '' $'--scheme=history --bind=ctrl-r:toggle-sort --wrap-sign \"\t↳ \" --highlight-line ($env.FZF_CTRL_R_OPTS) +m --read0');
|
||||||
|
let fzfcmd = (__fzfcmd);
|
||||||
|
let fzf_args = ($fzfcmd | skip 1);
|
||||||
|
# reverse | uniq: show most recent first, deduplicate keeping the latest.
|
||||||
|
# Nushell's `history` loads the full history as an in-memory table
|
||||||
|
# (bounded by $env.config.history.max_size, default 100,000), so
|
||||||
|
# reverse and uniq run on an already-materialized list. This is O(n)
|
||||||
|
# but acceptable for typical history sizes; unlike bash/zsh `fc -r`,
|
||||||
|
# there is no streaming primitive that would let fzf show the latest
|
||||||
|
# entries before the full list is consumed.
|
||||||
|
history
|
||||||
|
| get command
|
||||||
|
| reverse
|
||||||
|
| uniq
|
||||||
|
| str join (char -i 0)
|
||||||
|
| with-env { FZF_DEFAULT_OPTS: $fzf_opts, FZF_DEFAULT_OPTS_FILE: '' } { ^($fzfcmd | first) ...$fzf_args --query (commandline) }
|
||||||
|
| decode utf-8
|
||||||
|
| str trim
|
||||||
|
)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Files
|
||||||
|
const ctrl_t = {
|
||||||
|
name: fzf_files
|
||||||
|
modifier: control
|
||||||
|
keycode: char_t
|
||||||
|
mode: [emacs, vi_normal, vi_insert]
|
||||||
|
event: [
|
||||||
|
{
|
||||||
|
send: executehostcommand
|
||||||
|
cmd: "
|
||||||
|
let fzf_opts = (__fzf_defaults '--reverse --walker=file,dir,follow,hidden --scheme=path' $'($env.FZF_CTRL_T_OPTS) -m');
|
||||||
|
let fzfcmd = (__fzfcmd);
|
||||||
|
let fzf_args = ($fzfcmd | skip 1);
|
||||||
|
let ctrl_t_cmd = ($env.FZF_CTRL_T_COMMAND? | default null);
|
||||||
|
let result = if ($ctrl_t_cmd == null) or ($ctrl_t_cmd | is-empty) {
|
||||||
|
with-env { FZF_DEFAULT_OPTS: $fzf_opts, FZF_DEFAULT_OPTS_FILE: '' } { ^($fzfcmd | first) ...$fzf_args }
|
||||||
|
} else {
|
||||||
|
let fzf_cmd_str = ($fzfcmd | str join ' ');
|
||||||
|
let sh_cmd = [$ctrl_t_cmd '|' $fzf_cmd_str] | str join ' ';
|
||||||
|
with-env { FZF_DEFAULT_OPTS: $fzf_opts, FZF_DEFAULT_OPTS_FILE: '' } { ^sh -c $sh_cmd }
|
||||||
|
};
|
||||||
|
let result = ($result | str replace --all (char newline) ' ' | str trim);
|
||||||
|
commandline edit --append $result;
|
||||||
|
commandline set-cursor --end
|
||||||
|
"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Helper to check if a binding is enabled. A binding is disabled when
|
||||||
|
# the corresponding *_COMMAND variable is explicitly set to "".
|
||||||
|
# When not defined (null), the binding is enabled (using fzf's built-in walker).
|
||||||
|
def __fzf_binding_enabled [var_name: string]: nothing -> bool {
|
||||||
|
let val = ($env | get -o $var_name)
|
||||||
|
# null = not defined = enabled; "" = explicitly disabled
|
||||||
|
$val == null or ($val | into string | is-not-empty)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update the $env.config
|
||||||
|
export-env {
|
||||||
|
let fzf_names = ['fzf_files', 'fzf_dirs', 'fzf_history']
|
||||||
|
# Filter out any existing fzf bindings, then re-add the enabled ones.
|
||||||
|
# This allows re-sourcing to update bindings (e.g. after changing
|
||||||
|
# FZF_CTRL_T_COMMAND) without creating duplicates.
|
||||||
|
mut bindings = ($env.config.keybindings | where { |kb| $kb.name not-in $fzf_names })
|
||||||
|
if (__fzf_binding_enabled 'FZF_ALT_C_COMMAND') { $bindings = ($bindings | append $alt_c) }
|
||||||
|
if (__fzf_binding_enabled 'FZF_CTRL_R_COMMAND') { $bindings = ($bindings | append $ctrl_r) }
|
||||||
|
if (__fzf_binding_enabled 'FZF_CTRL_T_COMMAND') { $bindings = ($bindings | append $ctrl_t) }
|
||||||
|
$env.config.keybindings = $bindings
|
||||||
|
}
|
||||||
+11
-6
@@ -4,7 +4,6 @@
|
|||||||
# / __/ / /_/ __/
|
# / __/ / /_/ __/
|
||||||
# /_/ /___/_/ key-bindings.zsh
|
# /_/ /___/_/ key-bindings.zsh
|
||||||
#
|
#
|
||||||
# - $FZF_TMUX_OPTS
|
|
||||||
# - $FZF_CTRL_T_COMMAND
|
# - $FZF_CTRL_T_COMMAND
|
||||||
# - $FZF_CTRL_T_OPTS
|
# - $FZF_CTRL_T_OPTS
|
||||||
# - $FZF_CTRL_R_COMMAND
|
# - $FZF_CTRL_R_COMMAND
|
||||||
@@ -45,9 +44,9 @@ if [[ -o interactive ]]; then
|
|||||||
# the changes. See code comments in "common.sh" for the implementation details.
|
# the changes. See code comments in "common.sh" for the implementation details.
|
||||||
|
|
||||||
__fzf_defaults() {
|
__fzf_defaults() {
|
||||||
printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
|
builtin printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
|
||||||
command cat "${FZF_DEFAULT_OPTS_FILE-}" 2> /dev/null
|
command cat "${FZF_DEFAULT_OPTS_FILE-}" 2> /dev/null
|
||||||
printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
|
builtin printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
|
||||||
}
|
}
|
||||||
|
|
||||||
__fzf_exec_awk() {
|
__fzf_exec_awk() {
|
||||||
@@ -111,8 +110,14 @@ fzf-cd-widget() {
|
|||||||
zle redisplay
|
zle redisplay
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
# Use subshell expansion to get the absolute PWD of the target dir.
|
||||||
|
# This allows the recorded shell history to be reused even from a different
|
||||||
|
# working directory.
|
||||||
|
# If failed, fallback to the unexpanded path to surface the error to the user.
|
||||||
|
# NOTE: Don't use the `:a` modifier as it resolves symlinks like `pwd -P`.
|
||||||
|
dir=$(builtin cd -q >/dev/null -- "${dir}" && echo "${PWD}" || echo "${dir}")
|
||||||
zle push-line # Clear buffer. Auto-restored on next prompt.
|
zle push-line # Clear buffer. Auto-restored on next prompt.
|
||||||
BUFFER="builtin cd -- ${(q)dir:a}"
|
BUFFER="builtin cd -- ${(q)dir}"
|
||||||
zle accept-line
|
zle accept-line
|
||||||
local ret=$?
|
local ret=$?
|
||||||
unset dir # ensure this doesn't end up appearing in prompt expansion
|
unset dir # ensure this doesn't end up appearing in prompt expansion
|
||||||
@@ -129,16 +134,16 @@ fi
|
|||||||
# CTRL-R - Paste the selected command from history into the command line
|
# CTRL-R - Paste the selected command from history into the command line
|
||||||
fzf-history-widget() {
|
fzf-history-widget() {
|
||||||
local selected extracted_with_perl=0
|
local selected extracted_with_perl=0
|
||||||
setopt localoptions noglobsubst noposixbuiltins pipefail no_aliases no_glob no_ksharrays extendedglob 2> /dev/null
|
setopt localoptions noglobsubst noposixbuiltins pipefail no_aliases no_glob no_sh_glob no_ksharrays extendedglob 2> /dev/null
|
||||||
# Ensure the module is loaded if not already, and the required features, such
|
# Ensure the module is loaded if not already, and the required features, such
|
||||||
# as the associative 'history' array, which maps event numbers to full history
|
# as the associative 'history' array, which maps event numbers to full history
|
||||||
# lines, are set. Also, make sure Perl is installed for multi-line output.
|
# lines, are set. Also, make sure Perl is installed for multi-line output.
|
||||||
if zmodload -F zsh/parameter p:{commands,history} 2>/dev/null && (( ${+commands[perl]} )); then
|
if zmodload -F zsh/parameter p:{commands,history} 2>/dev/null && (( ${+commands[perl]} )); then
|
||||||
|
extracted_with_perl=1
|
||||||
selected="$(printf '%s\t%s\000' "${(kv)history[@]}" |
|
selected="$(printf '%s\t%s\000' "${(kv)history[@]}" |
|
||||||
perl -0 -ne 'if (!$seen{(/^\s*[0-9]+\**\t(.*)/s, $1)}++) { s/\n/\n\t/g; print; }' |
|
perl -0 -ne 'if (!$seen{(/^\s*[0-9]+\**\t(.*)/s, $1)}++) { s/\n/\n\t/g; print; }' |
|
||||||
FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '\t↳ ' --highlight-line --multi ${FZF_CTRL_R_OPTS-} --query=${(qqq)LBUFFER} --read0") \
|
FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '\t↳ ' --highlight-line --multi ${FZF_CTRL_R_OPTS-} --query=${(qqq)LBUFFER} --read0") \
|
||||||
FZF_DEFAULT_OPTS_FILE='' $(__fzfcmd))"
|
FZF_DEFAULT_OPTS_FILE='' $(__fzfcmd))"
|
||||||
extracted_with_perl=1
|
|
||||||
else
|
else
|
||||||
selected="$(fc -rl 1 | __fzf_exec_awk '{ cmd=$0; sub(/^[ \t]*[0-9]+\**[ \t]+/, "", cmd); if (!seen[cmd]++) print $0 }' |
|
selected="$(fc -rl 1 | __fzf_exec_awk '{ cmd=$0; sub(/^[ \t]*[0-9]+\**[ \t]+/, "", cmd); if (!seen[cmd]++) print $0 }' |
|
||||||
FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '\t↳ ' --highlight-line --multi ${FZF_CTRL_R_OPTS-} --query=${(qqq)LBUFFER}") \
|
FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '\t↳ ' --highlight-line --multi ${FZF_CTRL_R_OPTS-} --query=${(qqq)LBUFFER}") \
|
||||||
|
|||||||
+12
-14
@@ -8,26 +8,24 @@ dir=${0%"${0##*/}"}
|
|||||||
|
|
||||||
update() {
|
update() {
|
||||||
{
|
{
|
||||||
sed -n "1,/^#----BEGIN INCLUDE $1/p" "$2"
|
sed -n '1,/^#----BEGIN INCLUDE common\.sh/p' "$1"
|
||||||
cat << EOF
|
cat << EOF
|
||||||
# NOTE: Do not directly edit this section, which is copied from "$1".
|
# NOTE: Do not directly edit this section, which is copied from "common.sh".
|
||||||
# To modify it, one can edit "$1" and run "./update.sh" to apply
|
# To modify it, one can edit "common.sh" and run "./update.sh" to apply
|
||||||
# the changes. See code comments in "$1" for the implementation details.
|
# the changes. See code comments in "common.sh" for the implementation details.
|
||||||
EOF
|
EOF
|
||||||
echo
|
echo
|
||||||
grep -v '^[[:blank:]]*#' "$dir/$1" # remove code comments from the common file
|
grep -v '^[[:blank:]]*#' "$dir/common.sh" # remove code comments in common.sh
|
||||||
sed -n '/^#----END INCLUDE/,$p' "$2"
|
sed -n '/^#----END INCLUDE/,$p' "$1"
|
||||||
} > "$2.part"
|
} > "$1.part"
|
||||||
|
|
||||||
mv -f "$2.part" "$2"
|
mv -f "$1.part" "$1"
|
||||||
}
|
}
|
||||||
|
|
||||||
update "common.sh" "$dir/completion.bash"
|
update "$dir/completion.bash"
|
||||||
update "common.sh" "$dir/completion.zsh"
|
update "$dir/completion.zsh"
|
||||||
update "common.sh" "$dir/key-bindings.bash"
|
update "$dir/key-bindings.bash"
|
||||||
update "common.sh" "$dir/key-bindings.zsh"
|
update "$dir/key-bindings.zsh"
|
||||||
update "common.fish" "$dir/completion.fish"
|
|
||||||
update "common.fish" "$dir/key-bindings.fish"
|
|
||||||
|
|
||||||
# Check if --check is in ARGV
|
# Check if --check is in ARGV
|
||||||
check=0
|
check=0
|
||||||
|
|||||||
+159
-152
@@ -30,161 +30,168 @@ func _() {
|
|||||||
_ = x[actChangeBorderLabel-19]
|
_ = x[actChangeBorderLabel-19]
|
||||||
_ = x[actChangeGhost-20]
|
_ = x[actChangeGhost-20]
|
||||||
_ = x[actChangeHeader-21]
|
_ = x[actChangeHeader-21]
|
||||||
_ = x[actChangeFooter-22]
|
_ = x[actChangeHeaderLines-22]
|
||||||
_ = x[actChangeHeaderLabel-23]
|
_ = x[actChangeFooter-23]
|
||||||
_ = x[actChangeFooterLabel-24]
|
_ = x[actChangeHeaderLabel-24]
|
||||||
_ = x[actChangeInputLabel-25]
|
_ = x[actChangeFooterLabel-25]
|
||||||
_ = x[actChangeListLabel-26]
|
_ = x[actChangeInputLabel-26]
|
||||||
_ = x[actChangeMulti-27]
|
_ = x[actChangeListLabel-27]
|
||||||
_ = x[actChangeNth-28]
|
_ = x[actChangeMulti-28]
|
||||||
_ = x[actChangePointer-29]
|
_ = x[actChangeNth-29]
|
||||||
_ = x[actChangePreview-30]
|
_ = x[actChangeWithNth-30]
|
||||||
_ = x[actChangePreviewLabel-31]
|
_ = x[actChangePointer-31]
|
||||||
_ = x[actChangePreviewWindow-32]
|
_ = x[actChangePreview-32]
|
||||||
_ = x[actChangePrompt-33]
|
_ = x[actChangePreviewLabel-33]
|
||||||
_ = x[actChangeQuery-34]
|
_ = x[actChangePreviewWindow-34]
|
||||||
_ = x[actClearScreen-35]
|
_ = x[actChangePrompt-35]
|
||||||
_ = x[actClearQuery-36]
|
_ = x[actChangeQuery-36]
|
||||||
_ = x[actClearSelection-37]
|
_ = x[actClearScreen-37]
|
||||||
_ = x[actClose-38]
|
_ = x[actClearQuery-38]
|
||||||
_ = x[actDeleteChar-39]
|
_ = x[actClearSelection-39]
|
||||||
_ = x[actDeleteCharEof-40]
|
_ = x[actClose-40]
|
||||||
_ = x[actEndOfLine-41]
|
_ = x[actDeleteChar-41]
|
||||||
_ = x[actFatal-42]
|
_ = x[actDeleteCharEof-42]
|
||||||
_ = x[actForwardChar-43]
|
_ = x[actEndOfLine-43]
|
||||||
_ = x[actForwardWord-44]
|
_ = x[actFatal-44]
|
||||||
_ = x[actForwardSubWord-45]
|
_ = x[actForwardChar-45]
|
||||||
_ = x[actKillLine-46]
|
_ = x[actForwardWord-46]
|
||||||
_ = x[actKillWord-47]
|
_ = x[actForwardSubWord-47]
|
||||||
_ = x[actKillSubWord-48]
|
_ = x[actKillLine-48]
|
||||||
_ = x[actUnixLineDiscard-49]
|
_ = x[actKillWord-49]
|
||||||
_ = x[actUnixWordRubout-50]
|
_ = x[actKillSubWord-50]
|
||||||
_ = x[actYank-51]
|
_ = x[actUnixLineDiscard-51]
|
||||||
_ = x[actBackwardKillWord-52]
|
_ = x[actUnixWordRubout-52]
|
||||||
_ = x[actBackwardKillSubWord-53]
|
_ = x[actYank-53]
|
||||||
_ = x[actSelectAll-54]
|
_ = x[actBackwardKillWord-54]
|
||||||
_ = x[actDeselectAll-55]
|
_ = x[actBackwardKillSubWord-55]
|
||||||
_ = x[actToggle-56]
|
_ = x[actSelectAll-56]
|
||||||
_ = x[actToggleSearch-57]
|
_ = x[actDeselectAll-57]
|
||||||
_ = x[actToggleAll-58]
|
_ = x[actToggle-58]
|
||||||
_ = x[actToggleDown-59]
|
_ = x[actToggleSearch-59]
|
||||||
_ = x[actToggleUp-60]
|
_ = x[actToggleAll-60]
|
||||||
_ = x[actToggleIn-61]
|
_ = x[actToggleDown-61]
|
||||||
_ = x[actToggleOut-62]
|
_ = x[actToggleUp-62]
|
||||||
_ = x[actToggleTrack-63]
|
_ = x[actToggleIn-63]
|
||||||
_ = x[actToggleTrackCurrent-64]
|
_ = x[actToggleOut-64]
|
||||||
_ = x[actToggleHeader-65]
|
_ = x[actToggleTrack-65]
|
||||||
_ = x[actToggleWrap-66]
|
_ = x[actToggleTrackCurrent-66]
|
||||||
_ = x[actToggleWrapWord-67]
|
_ = x[actToggleHeader-67]
|
||||||
_ = x[actToggleMultiLine-68]
|
_ = x[actToggleWrap-68]
|
||||||
_ = x[actToggleHscroll-69]
|
_ = x[actToggleWrapWord-69]
|
||||||
_ = x[actToggleRaw-70]
|
_ = x[actToggleMultiLine-70]
|
||||||
_ = x[actEnableRaw-71]
|
_ = x[actToggleHscroll-71]
|
||||||
_ = x[actDisableRaw-72]
|
_ = x[actToggleRaw-72]
|
||||||
_ = x[actTrackCurrent-73]
|
_ = x[actEnableRaw-73]
|
||||||
_ = x[actToggleInput-74]
|
_ = x[actDisableRaw-74]
|
||||||
_ = x[actHideInput-75]
|
_ = x[actTrackCurrent-75]
|
||||||
_ = x[actShowInput-76]
|
_ = x[actToggleInput-76]
|
||||||
_ = x[actUntrackCurrent-77]
|
_ = x[actHideInput-77]
|
||||||
_ = x[actDown-78]
|
_ = x[actShowInput-78]
|
||||||
_ = x[actDownMatch-79]
|
_ = x[actUntrackCurrent-79]
|
||||||
_ = x[actUp-80]
|
_ = x[actDown-80]
|
||||||
_ = x[actUpMatch-81]
|
_ = x[actDownMatch-81]
|
||||||
_ = x[actPageUp-82]
|
_ = x[actUp-82]
|
||||||
_ = x[actPageDown-83]
|
_ = x[actUpMatch-83]
|
||||||
_ = x[actPosition-84]
|
_ = x[actPageUp-84]
|
||||||
_ = x[actHalfPageUp-85]
|
_ = x[actPageDown-85]
|
||||||
_ = x[actHalfPageDown-86]
|
_ = x[actPosition-86]
|
||||||
_ = x[actOffsetUp-87]
|
_ = x[actHalfPageUp-87]
|
||||||
_ = x[actOffsetDown-88]
|
_ = x[actHalfPageDown-88]
|
||||||
_ = x[actOffsetMiddle-89]
|
_ = x[actOffsetUp-89]
|
||||||
_ = x[actJump-90]
|
_ = x[actOffsetDown-90]
|
||||||
_ = x[actJumpAccept-91]
|
_ = x[actOffsetMiddle-91]
|
||||||
_ = x[actPrintQuery-92]
|
_ = x[actJump-92]
|
||||||
_ = x[actRefreshPreview-93]
|
_ = x[actJumpAccept-93]
|
||||||
_ = x[actReplaceQuery-94]
|
_ = x[actPrintQuery-94]
|
||||||
_ = x[actToggleSort-95]
|
_ = x[actRefreshPreview-95]
|
||||||
_ = x[actShowPreview-96]
|
_ = x[actReplaceQuery-96]
|
||||||
_ = x[actHidePreview-97]
|
_ = x[actToggleSort-97]
|
||||||
_ = x[actTogglePreview-98]
|
_ = x[actShowPreview-98]
|
||||||
_ = x[actTogglePreviewWrap-99]
|
_ = x[actHidePreview-99]
|
||||||
_ = x[actTogglePreviewWrapWord-100]
|
_ = x[actTogglePreview-100]
|
||||||
_ = x[actTransform-101]
|
_ = x[actTogglePreviewWrap-101]
|
||||||
_ = x[actTransformBorderLabel-102]
|
_ = x[actTogglePreviewWrapWord-102]
|
||||||
_ = x[actTransformGhost-103]
|
_ = x[actTransform-103]
|
||||||
_ = x[actTransformHeader-104]
|
_ = x[actTransformBorderLabel-104]
|
||||||
_ = x[actTransformFooter-105]
|
_ = x[actTransformGhost-105]
|
||||||
_ = x[actTransformHeaderLabel-106]
|
_ = x[actTransformHeader-106]
|
||||||
_ = x[actTransformFooterLabel-107]
|
_ = x[actTransformHeaderLines-107]
|
||||||
_ = x[actTransformInputLabel-108]
|
_ = x[actTransformFooter-108]
|
||||||
_ = x[actTransformListLabel-109]
|
_ = x[actTransformHeaderLabel-109]
|
||||||
_ = x[actTransformNth-110]
|
_ = x[actTransformFooterLabel-110]
|
||||||
_ = x[actTransformPointer-111]
|
_ = x[actTransformInputLabel-111]
|
||||||
_ = x[actTransformPreviewLabel-112]
|
_ = x[actTransformListLabel-112]
|
||||||
_ = x[actTransformPrompt-113]
|
_ = x[actTransformNth-113]
|
||||||
_ = x[actTransformQuery-114]
|
_ = x[actTransformWithNth-114]
|
||||||
_ = x[actTransformSearch-115]
|
_ = x[actTransformPointer-115]
|
||||||
_ = x[actTrigger-116]
|
_ = x[actTransformPreviewLabel-116]
|
||||||
_ = x[actBgTransform-117]
|
_ = x[actTransformPrompt-117]
|
||||||
_ = x[actBgTransformBorderLabel-118]
|
_ = x[actTransformQuery-118]
|
||||||
_ = x[actBgTransformGhost-119]
|
_ = x[actTransformSearch-119]
|
||||||
_ = x[actBgTransformHeader-120]
|
_ = x[actTrigger-120]
|
||||||
_ = x[actBgTransformFooter-121]
|
_ = x[actBgTransform-121]
|
||||||
_ = x[actBgTransformHeaderLabel-122]
|
_ = x[actBgTransformBorderLabel-122]
|
||||||
_ = x[actBgTransformFooterLabel-123]
|
_ = x[actBgTransformGhost-123]
|
||||||
_ = x[actBgTransformInputLabel-124]
|
_ = x[actBgTransformHeader-124]
|
||||||
_ = x[actBgTransformListLabel-125]
|
_ = x[actBgTransformHeaderLines-125]
|
||||||
_ = x[actBgTransformNth-126]
|
_ = x[actBgTransformFooter-126]
|
||||||
_ = x[actBgTransformPointer-127]
|
_ = x[actBgTransformHeaderLabel-127]
|
||||||
_ = x[actBgTransformPreviewLabel-128]
|
_ = x[actBgTransformFooterLabel-128]
|
||||||
_ = x[actBgTransformPrompt-129]
|
_ = x[actBgTransformInputLabel-129]
|
||||||
_ = x[actBgTransformQuery-130]
|
_ = x[actBgTransformListLabel-130]
|
||||||
_ = x[actBgTransformSearch-131]
|
_ = x[actBgTransformNth-131]
|
||||||
_ = x[actBgCancel-132]
|
_ = x[actBgTransformWithNth-132]
|
||||||
_ = x[actSearch-133]
|
_ = x[actBgTransformPointer-133]
|
||||||
_ = x[actPreview-134]
|
_ = x[actBgTransformPreviewLabel-134]
|
||||||
_ = x[actPreviewTop-135]
|
_ = x[actBgTransformPrompt-135]
|
||||||
_ = x[actPreviewBottom-136]
|
_ = x[actBgTransformQuery-136]
|
||||||
_ = x[actPreviewUp-137]
|
_ = x[actBgTransformSearch-137]
|
||||||
_ = x[actPreviewDown-138]
|
_ = x[actBgCancel-138]
|
||||||
_ = x[actPreviewPageUp-139]
|
_ = x[actSearch-139]
|
||||||
_ = x[actPreviewPageDown-140]
|
_ = x[actPreview-140]
|
||||||
_ = x[actPreviewHalfPageUp-141]
|
_ = x[actPreviewTop-141]
|
||||||
_ = x[actPreviewHalfPageDown-142]
|
_ = x[actPreviewBottom-142]
|
||||||
_ = x[actPrevHistory-143]
|
_ = x[actPreviewUp-143]
|
||||||
_ = x[actPrevSelected-144]
|
_ = x[actPreviewDown-144]
|
||||||
_ = x[actPrint-145]
|
_ = x[actPreviewPageUp-145]
|
||||||
_ = x[actPut-146]
|
_ = x[actPreviewPageDown-146]
|
||||||
_ = x[actNextHistory-147]
|
_ = x[actPreviewHalfPageUp-147]
|
||||||
_ = x[actNextSelected-148]
|
_ = x[actPreviewHalfPageDown-148]
|
||||||
_ = x[actExecute-149]
|
_ = x[actPrevHistory-149]
|
||||||
_ = x[actExecuteSilent-150]
|
_ = x[actPrevSelected-150]
|
||||||
_ = x[actExecuteMulti-151]
|
_ = x[actPrint-151]
|
||||||
_ = x[actSigStop-152]
|
_ = x[actPut-152]
|
||||||
_ = x[actBest-153]
|
_ = x[actNextHistory-153]
|
||||||
_ = x[actFirst-154]
|
_ = x[actNextSelected-154]
|
||||||
_ = x[actLast-155]
|
_ = x[actExecute-155]
|
||||||
_ = x[actReload-156]
|
_ = x[actExecuteSilent-156]
|
||||||
_ = x[actReloadSync-157]
|
_ = x[actExecuteMulti-157]
|
||||||
_ = x[actDisableSearch-158]
|
_ = x[actSigStop-158]
|
||||||
_ = x[actEnableSearch-159]
|
_ = x[actBest-159]
|
||||||
_ = x[actSelect-160]
|
_ = x[actFirst-160]
|
||||||
_ = x[actDeselect-161]
|
_ = x[actLast-161]
|
||||||
_ = x[actUnbind-162]
|
_ = x[actReload-162]
|
||||||
_ = x[actRebind-163]
|
_ = x[actReloadSync-163]
|
||||||
_ = x[actToggleBind-164]
|
_ = x[actDisableSearch-164]
|
||||||
_ = x[actBecome-165]
|
_ = x[actEnableSearch-165]
|
||||||
_ = x[actShowHeader-166]
|
_ = x[actSelect-166]
|
||||||
_ = x[actHideHeader-167]
|
_ = x[actDeselect-167]
|
||||||
_ = x[actBell-168]
|
_ = x[actUnbind-168]
|
||||||
_ = x[actExclude-169]
|
_ = x[actRebind-169]
|
||||||
_ = x[actExcludeMulti-170]
|
_ = x[actToggleBind-170]
|
||||||
_ = x[actAsync-171]
|
_ = x[actBecome-171]
|
||||||
|
_ = x[actShowHeader-172]
|
||||||
|
_ = x[actHideHeader-173]
|
||||||
|
_ = x[actBell-174]
|
||||||
|
_ = x[actExclude-175]
|
||||||
|
_ = x[actExcludeMulti-176]
|
||||||
|
_ = x[actAsync-177]
|
||||||
|
_ = x[actWait-178]
|
||||||
}
|
}
|
||||||
|
|
||||||
const _actionType_name = "actIgnoreactStartactClickactInvalidactBracketedPasteBeginactBracketedPasteEndactCharactMouseactBeginningOfLineactAbortactAcceptactAcceptNonEmptyactAcceptOrPrintQueryactBackwardCharactBackwardDeleteCharactBackwardDeleteCharEofactBackwardWordactBackwardSubWordactCancelactChangeBorderLabelactChangeGhostactChangeHeaderactChangeFooteractChangeHeaderLabelactChangeFooterLabelactChangeInputLabelactChangeListLabelactChangeMultiactChangeNthactChangePointeractChangePreviewactChangePreviewLabelactChangePreviewWindowactChangePromptactChangeQueryactClearScreenactClearQueryactClearSelectionactCloseactDeleteCharactDeleteCharEofactEndOfLineactFatalactForwardCharactForwardWordactForwardSubWordactKillLineactKillWordactKillSubWordactUnixLineDiscardactUnixWordRuboutactYankactBackwardKillWordactBackwardKillSubWordactSelectAllactDeselectAllactToggleactToggleSearchactToggleAllactToggleDownactToggleUpactToggleInactToggleOutactToggleTrackactToggleTrackCurrentactToggleHeaderactToggleWrapactToggleWrapWordactToggleMultiLineactToggleHscrollactToggleRawactEnableRawactDisableRawactTrackCurrentactToggleInputactHideInputactShowInputactUntrackCurrentactDownactDownMatchactUpactUpMatchactPageUpactPageDownactPositionactHalfPageUpactHalfPageDownactOffsetUpactOffsetDownactOffsetMiddleactJumpactJumpAcceptactPrintQueryactRefreshPreviewactReplaceQueryactToggleSortactShowPreviewactHidePreviewactTogglePreviewactTogglePreviewWrapactTogglePreviewWrapWordactTransformactTransformBorderLabelactTransformGhostactTransformHeaderactTransformFooteractTransformHeaderLabelactTransformFooterLabelactTransformInputLabelactTransformListLabelactTransformNthactTransformPointeractTransformPreviewLabelactTransformPromptactTransformQueryactTransformSearchactTriggeractBgTransformactBgTransformBorderLabelactBgTransformGhostactBgTransformHeaderactBgTransformFooteractBgTransformHeaderLabelactBgTransformFooterLabelactBgTransformInputLabelactBgTransformListLabelactBgTransformNthactBgTransformPointeractBgTransformPreviewLabelactBgTransformPromptactBgTransformQueryactBgTransformSearchactBgCancelactSearchactPreviewactPreviewTopactPreviewBottomactPreviewUpactPreviewDownactPreviewPageUpactPreviewPageDownactPreviewHalfPageUpactPreviewHalfPageDownactPrevHistoryactPrevSelectedactPrintactPutactNextHistoryactNextSelectedactExecuteactExecuteSilentactExecuteMultiactSigStopactBestactFirstactLastactReloadactReloadSyncactDisableSearchactEnableSearchactSelectactDeselectactUnbindactRebindactToggleBindactBecomeactShowHeaderactHideHeaderactBellactExcludeactExcludeMultiactAsync"
|
const _actionType_name = "actIgnoreactStartactClickactInvalidactBracketedPasteBeginactBracketedPasteEndactCharactMouseactBeginningOfLineactAbortactAcceptactAcceptNonEmptyactAcceptOrPrintQueryactBackwardCharactBackwardDeleteCharactBackwardDeleteCharEofactBackwardWordactBackwardSubWordactCancelactChangeBorderLabelactChangeGhostactChangeHeaderactChangeHeaderLinesactChangeFooteractChangeHeaderLabelactChangeFooterLabelactChangeInputLabelactChangeListLabelactChangeMultiactChangeNthactChangeWithNthactChangePointeractChangePreviewactChangePreviewLabelactChangePreviewWindowactChangePromptactChangeQueryactClearScreenactClearQueryactClearSelectionactCloseactDeleteCharactDeleteCharEofactEndOfLineactFatalactForwardCharactForwardWordactForwardSubWordactKillLineactKillWordactKillSubWordactUnixLineDiscardactUnixWordRuboutactYankactBackwardKillWordactBackwardKillSubWordactSelectAllactDeselectAllactToggleactToggleSearchactToggleAllactToggleDownactToggleUpactToggleInactToggleOutactToggleTrackactToggleTrackCurrentactToggleHeaderactToggleWrapactToggleWrapWordactToggleMultiLineactToggleHscrollactToggleRawactEnableRawactDisableRawactTrackCurrentactToggleInputactHideInputactShowInputactUntrackCurrentactDownactDownMatchactUpactUpMatchactPageUpactPageDownactPositionactHalfPageUpactHalfPageDownactOffsetUpactOffsetDownactOffsetMiddleactJumpactJumpAcceptactPrintQueryactRefreshPreviewactReplaceQueryactToggleSortactShowPreviewactHidePreviewactTogglePreviewactTogglePreviewWrapactTogglePreviewWrapWordactTransformactTransformBorderLabelactTransformGhostactTransformHeaderactTransformHeaderLinesactTransformFooteractTransformHeaderLabelactTransformFooterLabelactTransformInputLabelactTransformListLabelactTransformNthactTransformWithNthactTransformPointeractTransformPreviewLabelactTransformPromptactTransformQueryactTransformSearchactTriggeractBgTransformactBgTransformBorderLabelactBgTransformGhostactBgTransformHeaderactBgTransformHeaderLinesactBgTransformFooteractBgTransformHeaderLabelactBgTransformFooterLabelactBgTransformInputLabelactBgTransformListLabelactBgTransformNthactBgTransformWithNthactBgTransformPointeractBgTransformPreviewLabelactBgTransformPromptactBgTransformQueryactBgTransformSearchactBgCancelactSearchactPreviewactPreviewTopactPreviewBottomactPreviewUpactPreviewDownactPreviewPageUpactPreviewPageDownactPreviewHalfPageUpactPreviewHalfPageDownactPrevHistoryactPrevSelectedactPrintactPutactNextHistoryactNextSelectedactExecuteactExecuteSilentactExecuteMultiactSigStopactBestactFirstactLastactReloadactReloadSyncactDisableSearchactEnableSearchactSelectactDeselectactUnbindactRebindactToggleBindactBecomeactShowHeaderactHideHeaderactBellactExcludeactExcludeMultiactAsyncactWait"
|
||||||
|
|
||||||
var _actionType_index = [...]uint16{0, 9, 17, 25, 35, 57, 77, 84, 92, 110, 118, 127, 144, 165, 180, 201, 225, 240, 258, 267, 287, 301, 316, 331, 351, 371, 390, 408, 422, 434, 450, 466, 487, 509, 524, 538, 552, 565, 582, 590, 603, 619, 631, 639, 653, 667, 684, 695, 706, 720, 738, 755, 762, 781, 803, 815, 829, 838, 853, 865, 878, 889, 900, 912, 926, 947, 962, 975, 992, 1010, 1026, 1038, 1050, 1063, 1078, 1092, 1104, 1116, 1133, 1140, 1152, 1157, 1167, 1176, 1187, 1198, 1211, 1226, 1237, 1250, 1265, 1272, 1285, 1298, 1315, 1330, 1343, 1357, 1371, 1387, 1407, 1431, 1443, 1466, 1483, 1501, 1519, 1542, 1565, 1587, 1608, 1623, 1642, 1666, 1684, 1701, 1719, 1729, 1743, 1768, 1787, 1807, 1827, 1852, 1877, 1901, 1924, 1941, 1962, 1988, 2008, 2027, 2047, 2058, 2067, 2077, 2090, 2106, 2118, 2132, 2148, 2166, 2186, 2208, 2222, 2237, 2245, 2251, 2265, 2280, 2290, 2306, 2321, 2331, 2338, 2346, 2353, 2362, 2375, 2391, 2406, 2415, 2426, 2435, 2444, 2457, 2466, 2479, 2492, 2499, 2509, 2524, 2532}
|
var _actionType_index = [...]uint16{0, 9, 17, 25, 35, 57, 77, 84, 92, 110, 118, 127, 144, 165, 180, 201, 225, 240, 258, 267, 287, 301, 316, 336, 351, 371, 391, 410, 428, 442, 454, 470, 486, 502, 523, 545, 560, 574, 588, 601, 618, 626, 639, 655, 667, 675, 689, 703, 720, 731, 742, 756, 774, 791, 798, 817, 839, 851, 865, 874, 889, 901, 914, 925, 936, 948, 962, 983, 998, 1011, 1028, 1046, 1062, 1074, 1086, 1099, 1114, 1128, 1140, 1152, 1169, 1176, 1188, 1193, 1203, 1212, 1223, 1234, 1247, 1262, 1273, 1286, 1301, 1308, 1321, 1334, 1351, 1366, 1379, 1393, 1407, 1423, 1443, 1467, 1479, 1502, 1519, 1537, 1560, 1578, 1601, 1624, 1646, 1667, 1682, 1701, 1720, 1744, 1762, 1779, 1797, 1807, 1821, 1846, 1865, 1885, 1910, 1930, 1955, 1980, 2004, 2027, 2044, 2065, 2086, 2112, 2132, 2151, 2171, 2182, 2191, 2201, 2214, 2230, 2242, 2256, 2272, 2290, 2310, 2332, 2346, 2361, 2369, 2375, 2389, 2404, 2414, 2430, 2445, 2455, 2462, 2470, 2477, 2486, 2499, 2515, 2530, 2539, 2550, 2559, 2568, 2581, 2590, 2603, 2616, 2623, 2633, 2648, 2656, 2663}
|
||||||
|
|
||||||
func (i actionType) String() string {
|
func (i actionType) String() string {
|
||||||
if i < 0 || i >= actionType(len(_actionType_index)-1) {
|
if i < 0 || i >= actionType(len(_actionType_index)-1) {
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# SIMD byte search: `indexByteTwo` / `lastIndexByteTwo`
|
||||||
|
|
||||||
|
## What these functions do
|
||||||
|
|
||||||
|
`indexByteTwo(s []byte, b1, b2 byte) int` -- returns the index of the
|
||||||
|
**first** occurrence of `b1` or `b2` in `s`, or `-1`.
|
||||||
|
|
||||||
|
`lastIndexByteTwo(s []byte, b1, b2 byte) int` -- returns the index of the
|
||||||
|
**last** occurrence of `b1` or `b2` in `s`, or `-1`.
|
||||||
|
|
||||||
|
They are used by the fuzzy matching algorithm (`algo.go`) to skip ahead
|
||||||
|
during case-insensitive search. Instead of calling `bytes.IndexByte` twice
|
||||||
|
(once for lowercase, once for uppercase), a single SIMD pass finds both at
|
||||||
|
once.
|
||||||
|
|
||||||
|
## File layout
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
| ------ | --------- |
|
||||||
|
| `indexbyte2_arm64.go` | Go declarations (`//go:noescape`) for ARM64 |
|
||||||
|
| `indexbyte2_arm64.s` | ARM64 NEON assembly (32-byte aligned blocks, syndrome extraction) |
|
||||||
|
| `indexbyte2_amd64.go` | Go declarations + AVX2 runtime detection for AMD64 |
|
||||||
|
| `indexbyte2_amd64.s` | AMD64 AVX2/SSE2 assembly with CPUID dispatch |
|
||||||
|
| `indexbyte2_other.go` | Pure Go fallback for all other architectures |
|
||||||
|
| `indexbyte2_test.go` | Unit tests, exhaustive tests, fuzz tests, and benchmarks |
|
||||||
|
|
||||||
|
## How the SIMD implementations work
|
||||||
|
|
||||||
|
**ARM64 (NEON):**
|
||||||
|
- Broadcasts both needle bytes into NEON registers (`VMOV`).
|
||||||
|
- Processes 32-byte aligned chunks. For each chunk, compares all bytes
|
||||||
|
against both needles (`VCMEQ`), ORs the results (`VORR`), and builds a
|
||||||
|
64-bit syndrome with 2 bits per byte.
|
||||||
|
- `indexByteTwo` uses `RBIT` + `CLZ` to find the lowest set bit (first match).
|
||||||
|
- `lastIndexByteTwo` scans backward and uses `CLZ` on the raw syndrome to
|
||||||
|
find the highest set bit (last match).
|
||||||
|
- Handles alignment and partial first/last blocks with bit masking.
|
||||||
|
- Adapted from Go's `internal/bytealg/indexbyte_arm64.s`.
|
||||||
|
|
||||||
|
**AMD64 (AVX2 with SSE2 fallback):**
|
||||||
|
- At init time, `cpuHasAVX2()` checks CPUID + XGETBV for AVX2 and OS YMM
|
||||||
|
support. The result is cached in `_useAVX2`.
|
||||||
|
- **AVX2 path** (inputs >= 32 bytes, when available):
|
||||||
|
- Broadcasts both needles via `VPBROADCASTB`.
|
||||||
|
- Processes 32-byte blocks: `VPCMPEQB` against both needles, `VPOR`, then
|
||||||
|
`VPMOVMSKB` to get a 32-bit mask.
|
||||||
|
- 5 instructions per loop iteration (vs 7 for SSE2) at 2x the throughput.
|
||||||
|
- `VZEROUPPER` before every return to avoid SSE/AVX transition penalties.
|
||||||
|
- **SSE2 fallback** (inputs < 32 bytes, or CPUs without AVX2):
|
||||||
|
- Broadcasts via `PUNPCKLBW` + `PSHUFL`.
|
||||||
|
- Processes 16-byte blocks: `PCMPEQB`, `POR`, `PMOVMSKB`.
|
||||||
|
- Small inputs (<16 bytes) are handled with page-boundary-safe loads.
|
||||||
|
- Both paths use `BSFL` (forward) / `BSRL` (reverse) for bit scanning.
|
||||||
|
- Adapted from Go's `internal/bytealg/indexbyte_amd64.s`.
|
||||||
|
|
||||||
|
**Fallback (other platforms):**
|
||||||
|
- `indexByteTwo` uses two `bytes.IndexByte` calls with scope-limiting
|
||||||
|
(search `b1` first, then limit the `b2` search to `s[:i1]`).
|
||||||
|
- `lastIndexByteTwo` uses a simple backward for loop.
|
||||||
|
|
||||||
|
## Running tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Unit + exhaustive tests
|
||||||
|
go test ./src/algo/ -run 'TestIndexByteTwo|TestLastIndexByteTwo' -v
|
||||||
|
|
||||||
|
# Fuzz tests (run for 10 seconds each)
|
||||||
|
go test ./src/algo/ -run '^$' -fuzz FuzzIndexByteTwo -fuzztime 10s
|
||||||
|
go test ./src/algo/ -run '^$' -fuzz FuzzLastIndexByteTwo -fuzztime 10s
|
||||||
|
|
||||||
|
# Cross-architecture: test amd64 on an arm64 Mac (via Rosetta)
|
||||||
|
GOARCH=amd64 go test ./src/algo/ -run 'TestIndexByteTwo|TestLastIndexByteTwo' -v
|
||||||
|
GOARCH=amd64 go test ./src/algo/ -run '^$' -fuzz FuzzIndexByteTwo -fuzztime 10s
|
||||||
|
GOARCH=amd64 go test ./src/algo/ -run '^$' -fuzz FuzzLastIndexByteTwo -fuzztime 10s
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running micro-benchmarks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# All indexByteTwo / lastIndexByteTwo benchmarks
|
||||||
|
go test ./src/algo/ -bench 'IndexByteTwo' -benchmem
|
||||||
|
|
||||||
|
# Specific size
|
||||||
|
go test ./src/algo/ -bench 'IndexByteTwo_1000'
|
||||||
|
```
|
||||||
|
|
||||||
|
Each benchmark compares the SIMD `asm` implementation against reference
|
||||||
|
implementations (`2xIndexByte` using `bytes.IndexByte`, and a simple `loop`).
|
||||||
|
|
||||||
|
## Correctness verification
|
||||||
|
|
||||||
|
The assembly is verified by three layers of testing:
|
||||||
|
|
||||||
|
1. **Table-driven tests** -- known inputs with expected outputs.
|
||||||
|
2. **Exhaustive tests** -- all lengths 0–256, every match position, no-match
|
||||||
|
cases, and both-bytes-present cases, compared against a simple loop
|
||||||
|
reference.
|
||||||
|
3. **Fuzz tests** -- randomized inputs via `testing.F`, compared against the
|
||||||
|
same loop reference.
|
||||||
+332
-25
@@ -266,7 +266,7 @@ func charClassOf(char rune) charClass {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func bonusFor(prevClass charClass, class charClass) int16 {
|
func bonusFor(prevClass charClass, class charClass) int16 {
|
||||||
if class > charNonWord {
|
if class >= charNonWord {
|
||||||
switch prevClass {
|
switch prevClass {
|
||||||
case charWhite:
|
case charWhite:
|
||||||
// Word boundary after whitespace
|
// Word boundary after whitespace
|
||||||
@@ -303,7 +303,9 @@ func bonusAt(input *util.Chars, idx int) int16 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func normalizeRune(r rune) rune {
|
func normalizeRune(r rune) rune {
|
||||||
if r < 0x00C0 || r > 0xFF61 {
|
// Every key of the map folds to ASCII, so a rune the bitmap rejects cannot
|
||||||
|
// be in it. TestNormalizedKeysAreFlagged verifies that.
|
||||||
|
if !util.MayFoldToAscii(r) {
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,22 +323,15 @@ type Algo func(caseSensitive bool, normalize bool, forward bool, input *util.Cha
|
|||||||
|
|
||||||
func trySkip(input *util.Chars, caseSensitive bool, b byte, from int) int {
|
func trySkip(input *util.Chars, caseSensitive bool, b byte, from int) int {
|
||||||
byteArray := input.Bytes()[from:]
|
byteArray := input.Bytes()[from:]
|
||||||
idx := bytes.IndexByte(byteArray, b)
|
// For case-insensitive search of a letter, search for both cases in one pass
|
||||||
if idx == 0 {
|
|
||||||
// Can't skip any further
|
|
||||||
return from
|
|
||||||
}
|
|
||||||
// We may need to search for the uppercase letter again. We don't have to
|
|
||||||
// consider normalization as we can be sure that this is an ASCII string.
|
|
||||||
if !caseSensitive && b >= 'a' && b <= 'z' {
|
if !caseSensitive && b >= 'a' && b <= 'z' {
|
||||||
if idx > 0 {
|
idx := IndexByteTwo(byteArray, b, b-32)
|
||||||
byteArray = byteArray[:idx]
|
if idx < 0 {
|
||||||
}
|
return -1
|
||||||
uidx := bytes.IndexByte(byteArray, b-32)
|
|
||||||
if uidx >= 0 {
|
|
||||||
idx = uidx
|
|
||||||
}
|
}
|
||||||
|
return from + idx
|
||||||
}
|
}
|
||||||
|
idx := bytes.IndexByte(byteArray, b)
|
||||||
if idx < 0 {
|
if idx < 0 {
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
@@ -352,11 +347,91 @@ func isAscii(runes []rune) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runePrefilterable reports whether scanning the rune array can decide this
|
||||||
|
// pattern against this item without missing a match. Phase 2 lowercases an
|
||||||
|
// uppercase text rune and then normalizes it, and the scan sees neither
|
||||||
|
// transform, so every pattern rune must be unreachable by them.
|
||||||
|
func runePrefilterable(input *util.Chars, pattern []rune, caseSensitive bool) bool {
|
||||||
|
if input.MayFoldToAscii() {
|
||||||
|
// A non-ASCII rune of this item could fold onto an ASCII pattern char
|
||||||
|
for _, r := range pattern {
|
||||||
|
if r < utf8.RuneSelf {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if caseSensitive {
|
||||||
|
// No case transform is applied, and normalization only ever produces
|
||||||
|
// ASCII, so nothing can reach a non-ASCII pattern rune
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, r := range pattern {
|
||||||
|
// Another rune must not lowercase onto this one. Being uncased is not
|
||||||
|
// enough by itself: U+00DF has no simple uppercase yet U+1E9E
|
||||||
|
// lowercases to it. Excluding the foldable set covers that.
|
||||||
|
if r >= utf8.RuneSelf &&
|
||||||
|
(unicode.ToUpper(r) != r || unicode.ToLower(r) != r || util.MayFoldToAscii(r)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// runeFuzzyIndex is asciiFuzzyIndex for rune-mode input. Only valid when
|
||||||
|
// runePrefilterable says so.
|
||||||
|
func runeFuzzyIndex(input *util.Chars, pattern []rune, caseSensitive bool) (int, int) {
|
||||||
|
runes := input.Runes()
|
||||||
|
firstIdx, idx, lastIdx := 0, 0, 0
|
||||||
|
var last rune
|
||||||
|
for pidx := range pattern {
|
||||||
|
last = pattern[pidx]
|
||||||
|
if last < utf8.RuneSelf {
|
||||||
|
idx = indexAsciiRune(runes, caseSensitive, byte(last), idx)
|
||||||
|
} else {
|
||||||
|
idx = indexRune(runes, last, idx)
|
||||||
|
}
|
||||||
|
if idx < 0 {
|
||||||
|
return -1, -1
|
||||||
|
}
|
||||||
|
if pidx == 0 && idx > 0 {
|
||||||
|
// Step back to find the right bonus point
|
||||||
|
firstIdx = idx - 1
|
||||||
|
}
|
||||||
|
lastIdx = idx
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the last appearance of the last character of the pattern to limit
|
||||||
|
// the search scope
|
||||||
|
if lastIdx+1 < len(runes) {
|
||||||
|
var end int
|
||||||
|
if last < utf8.RuneSelf {
|
||||||
|
end = lastIndexAsciiRune(runes, caseSensitive, byte(last), lastIdx+1)
|
||||||
|
} else {
|
||||||
|
end = lastIndexRune(runes, last, lastIdx+1)
|
||||||
|
}
|
||||||
|
if end >= 0 {
|
||||||
|
return firstIdx, end + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return firstIdx, lastIdx + 1
|
||||||
|
}
|
||||||
|
|
||||||
func asciiFuzzyIndex(input *util.Chars, pattern []rune, caseSensitive bool) (int, int) {
|
func asciiFuzzyIndex(input *util.Chars, pattern []rune, caseSensitive bool) (int, int) {
|
||||||
// Can't determine
|
|
||||||
if !input.IsBytes() {
|
if !input.IsBytes() {
|
||||||
|
if disableRunePrefilter {
|
||||||
return 0, input.Length()
|
return 0, input.Length()
|
||||||
}
|
}
|
||||||
|
// runePrefilterable does not inline, so keep the common case out of
|
||||||
|
// it: an ASCII pattern against an item that cannot fold to ASCII is
|
||||||
|
// always scannable, and both of these checks do inline.
|
||||||
|
if input.MayFoldToAscii() || !isAscii(pattern) {
|
||||||
|
if !runePrefilterable(input, pattern, caseSensitive) {
|
||||||
|
return 0, input.Length()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return runeFuzzyIndex(input, pattern, caseSensitive)
|
||||||
|
}
|
||||||
|
|
||||||
// Not possible
|
// Not possible
|
||||||
if !isAscii(pattern) {
|
if !isAscii(pattern) {
|
||||||
@@ -380,14 +455,17 @@ func asciiFuzzyIndex(input *util.Chars, pattern []rune, caseSensitive bool) (int
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Find the last appearance of the last character of the pattern to limit the search scope
|
// Find the last appearance of the last character of the pattern to limit the search scope
|
||||||
bu := b
|
|
||||||
if !caseSensitive && b >= 'a' && b <= 'z' {
|
|
||||||
bu = b - 32
|
|
||||||
}
|
|
||||||
scope := input.Bytes()[lastIdx:]
|
scope := input.Bytes()[lastIdx:]
|
||||||
for offset := len(scope) - 1; offset > 0; offset-- {
|
if len(scope) > 1 {
|
||||||
if scope[offset] == b || scope[offset] == bu {
|
tail := scope[1:]
|
||||||
return firstIdx, lastIdx + offset + 1
|
var end int
|
||||||
|
if !caseSensitive && b >= 'a' && b <= 'z' {
|
||||||
|
end = lastIndexByteTwo(tail, b, b-32)
|
||||||
|
} else {
|
||||||
|
end = bytes.LastIndexByte(tail, b)
|
||||||
|
}
|
||||||
|
if end >= 0 {
|
||||||
|
return firstIdx, lastIdx + 1 + end + 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return firstIdx, lastIdx + 1
|
return firstIdx, lastIdx + 1
|
||||||
@@ -429,6 +507,218 @@ func debugV2(T []rune, pattern []rune, F []int32, lastIdx int, H []int16, C []in
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fuzzyMatchV2Single is a fast path for a single-character ASCII pattern on
|
||||||
|
// ASCII input. Same scoring and tiebreaks as Phase 2 of FuzzyMatchV2, but
|
||||||
|
// jumps between occurrences instead of scanning every character, and
|
||||||
|
// allocates no arrays.
|
||||||
|
func fuzzyMatchV2Single(caseSensitive bool, forward bool, input *util.Chars, b byte, withPos bool) (Result, *[]int) {
|
||||||
|
byteArray := input.Bytes()
|
||||||
|
maxScore, maxScorePos := int16(0), -1
|
||||||
|
for idx := 0; idx < len(byteArray); {
|
||||||
|
idx = trySkip(input, caseSensitive, b, idx)
|
||||||
|
if idx < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
class := asciiCharClasses[byteArray[idx]]
|
||||||
|
prevClass := initialCharClass
|
||||||
|
if idx > 0 {
|
||||||
|
prevClass = asciiCharClasses[byteArray[idx-1]]
|
||||||
|
}
|
||||||
|
bonus := bonusMatrix[prevClass][class]
|
||||||
|
score := scoreMatch + bonus*bonusFirstCharMultiplier
|
||||||
|
if forward && score > maxScore || !forward && score >= maxScore {
|
||||||
|
maxScore, maxScorePos = score, idx
|
||||||
|
if forward && bonus >= bonusBoundary {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
if maxScorePos < 0 {
|
||||||
|
return Result{-1, -1, 0}, nil
|
||||||
|
}
|
||||||
|
result := Result{maxScorePos, maxScorePos + 1, int(maxScore)}
|
||||||
|
if !withPos {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
pos := []int{maxScorePos}
|
||||||
|
return result, &pos
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test hooks: force the general path instead of a fast path, so the two can
|
||||||
|
// be compared for equivalence.
|
||||||
|
var (
|
||||||
|
disableSingle bool
|
||||||
|
disableTwo bool
|
||||||
|
disableRunePrefilter bool
|
||||||
|
)
|
||||||
|
|
||||||
|
// fuzzyMatchV2Two is a fused fast path for a two-character ASCII pattern on
|
||||||
|
// ASCII input. It replicates Phase 2 (row 0) and Phase 3 (row 1) of
|
||||||
|
// FuzzyMatchV2 in a single pass, carrying the row-0 diagonal/left values and
|
||||||
|
// the row-1 left value as scalars instead of materializing score arrays.
|
||||||
|
// When withPos is set, the two DP rows are stored so the backtrace can
|
||||||
|
// recover the matched character positions, exactly as the general Phase 4.
|
||||||
|
func fuzzyMatchV2Two(caseSensitive bool, forward bool, input *util.Chars, pchar0 byte, pchar1 byte, minIdx int, maxIdx int, withPos bool, slab *util.Slab) (Result, *[]int) {
|
||||||
|
sl := input.Bytes()
|
||||||
|
N := maxIdx - minIdx
|
||||||
|
|
||||||
|
// Row storage, only needed for the backtrace
|
||||||
|
var H0, C0, H1, C1 []int16
|
||||||
|
if withPos {
|
||||||
|
o := 0
|
||||||
|
o, H0 = alloc16(o, slab, N)
|
||||||
|
o, C0 = alloc16(o, slab, N)
|
||||||
|
o, H1 = alloc16(o, slab, N)
|
||||||
|
_, C1 = alloc16(o, slab, N)
|
||||||
|
}
|
||||||
|
|
||||||
|
maxScore, maxScorePos := int16(0), 0
|
||||||
|
prevClass := initialCharClass
|
||||||
|
|
||||||
|
// Subsequence tracking (equivalent to F[0], F[1] in Phase 2). The scope
|
||||||
|
// from asciiFuzzyIndex ends exactly at the last pchar1, so row 1's upper
|
||||||
|
// bound (Phase 3 lastIdx) is the final loop position; no separate var.
|
||||||
|
f0, f1 := -1, -1
|
||||||
|
|
||||||
|
// Row 0 running state at the previous position
|
||||||
|
var h0Prev, c0Prev, bPrev int16
|
||||||
|
inGap0 := false
|
||||||
|
|
||||||
|
// Row 1 running state
|
||||||
|
var h1Prev int16
|
||||||
|
inGap1 := false
|
||||||
|
|
||||||
|
for off := range N {
|
||||||
|
pos := minIdx + off
|
||||||
|
b := sl[pos]
|
||||||
|
class := asciiCharClasses[b]
|
||||||
|
lb := b
|
||||||
|
if !caseSensitive && b >= 'A' && b <= 'Z' {
|
||||||
|
lb = b + 32
|
||||||
|
}
|
||||||
|
bonus := bonusMatrix[prevClass][class]
|
||||||
|
prevClass = class
|
||||||
|
|
||||||
|
// Subsequence advance: pchar0 then pchar1
|
||||||
|
if f0 < 0 {
|
||||||
|
if lb == pchar0 {
|
||||||
|
f0 = off
|
||||||
|
}
|
||||||
|
} else if lb == pchar1 && f1 < 0 {
|
||||||
|
f1 = off
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row 0 (pchar0)
|
||||||
|
var h0Cur, c0Cur int16
|
||||||
|
if lb == pchar0 {
|
||||||
|
h0Cur = scoreMatch + bonus*bonusFirstCharMultiplier
|
||||||
|
c0Cur = 1
|
||||||
|
inGap0 = false
|
||||||
|
} else {
|
||||||
|
if inGap0 {
|
||||||
|
h0Cur = max(h0Prev+scoreGapExtension, 0)
|
||||||
|
} else {
|
||||||
|
h0Cur = max(h0Prev+scoreGapStart, 0)
|
||||||
|
}
|
||||||
|
c0Cur = 0
|
||||||
|
inGap0 = true
|
||||||
|
}
|
||||||
|
if withPos {
|
||||||
|
H0[off], C0[off] = h0Cur, c0Cur
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row 1 (pchar1), only within [f1, lastIdx]
|
||||||
|
if f1 >= 0 && off >= f1 {
|
||||||
|
var s1, s2, consecutive int16
|
||||||
|
hleft := h1Prev
|
||||||
|
if off == f1 {
|
||||||
|
hleft = 0
|
||||||
|
}
|
||||||
|
if inGap1 {
|
||||||
|
s2 = hleft + scoreGapExtension
|
||||||
|
} else {
|
||||||
|
s2 = hleft + scoreGapStart
|
||||||
|
}
|
||||||
|
if lb == pchar1 {
|
||||||
|
s1 = h0Prev + scoreMatch
|
||||||
|
bb := bonus
|
||||||
|
consecutive = c0Prev + 1
|
||||||
|
if consecutive > 1 {
|
||||||
|
fb := bPrev
|
||||||
|
if bb >= bonusBoundary && bb > fb {
|
||||||
|
consecutive = 1
|
||||||
|
} else {
|
||||||
|
bb = max(bb, bonusConsecutive, fb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s1+bb < s2 {
|
||||||
|
s1 += bonus
|
||||||
|
consecutive = 0
|
||||||
|
} else {
|
||||||
|
s1 += bb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inGap1 = s1 < s2
|
||||||
|
score := max(s1, s2, 0)
|
||||||
|
if forward && score > maxScore || !forward && score >= maxScore {
|
||||||
|
maxScore, maxScorePos = score, off
|
||||||
|
}
|
||||||
|
h1Prev = score
|
||||||
|
if withPos {
|
||||||
|
H1[off], C1[off] = score, consecutive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h0Prev, c0Prev, bPrev = h0Cur, c0Cur, bonus
|
||||||
|
}
|
||||||
|
|
||||||
|
if f1 < 0 {
|
||||||
|
return Result{-1, -1, 0}, nil
|
||||||
|
}
|
||||||
|
if !withPos {
|
||||||
|
return Result{minIdx + f0, minIdx + maxScorePos + 1, int(maxScore)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 4 backtrace, specialized to two rows. Mirrors the general loop:
|
||||||
|
// record a cell when it dominates its diagonal and left neighbors, then
|
||||||
|
// step up a row; otherwise step left. preferMatch breaks score ties and
|
||||||
|
// must not read row 1 left of f1 (unwritten, possibly stale slab data).
|
||||||
|
pos := posArray(true, 2)
|
||||||
|
i := 1
|
||||||
|
j := maxScorePos
|
||||||
|
preferMatch := true
|
||||||
|
for {
|
||||||
|
var s, s1, s2, cCur int16
|
||||||
|
if i == 1 {
|
||||||
|
s, cCur = H1[j], C1[j]
|
||||||
|
if j >= f1 {
|
||||||
|
s1 = H0[j-1]
|
||||||
|
}
|
||||||
|
if j > f1 {
|
||||||
|
s2 = H1[j-1]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
s, cCur = H0[j], C0[j]
|
||||||
|
if j > f0 {
|
||||||
|
s2 = H0[j-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row := i
|
||||||
|
if s > s1 && (s > s2 || s == s2 && preferMatch) {
|
||||||
|
*pos = append(*pos, j+minIdx)
|
||||||
|
if i == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
i--
|
||||||
|
}
|
||||||
|
preferMatch = cCur > 1 ||
|
||||||
|
row == 0 && j < N-1 && j+1 >= f1 && C1[j+1] > 0
|
||||||
|
j--
|
||||||
|
}
|
||||||
|
return Result{minIdx + j, minIdx + maxScorePos + 1, int(maxScore)}, pos
|
||||||
|
}
|
||||||
|
|
||||||
func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.Chars, pattern []rune, withPos bool, slab *util.Slab) (Result, *[]int) {
|
func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.Chars, pattern []rune, withPos bool, slab *util.Slab) (Result, *[]int) {
|
||||||
// Assume that pattern is given in lowercase if case-insensitive.
|
// Assume that pattern is given in lowercase if case-insensitive.
|
||||||
// First check if there's a match and calculate bonus for each position.
|
// First check if there's a match and calculate bonus for each position.
|
||||||
@@ -447,10 +737,16 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
|
|||||||
// we fall back to the greedy algorithm.
|
// we fall back to the greedy algorithm.
|
||||||
// Also, we should not allow a very long pattern to avoid 16-bit integer
|
// Also, we should not allow a very long pattern to avoid 16-bit integer
|
||||||
// overflow in the score matrix. 1000 is a safe limit.
|
// overflow in the score matrix. 1000 is a safe limit.
|
||||||
if slab != nil && N*M > cap(slab.I16) || M > 1000 {
|
if slab != nil && int64(N)*int64(M) > int64(cap(slab.I16)) || M > 1000 {
|
||||||
return FuzzyMatchV1(caseSensitive, normalize, forward, input, pattern, withPos, slab)
|
return FuzzyMatchV1(caseSensitive, normalize, forward, input, pattern, withPos, slab)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Single-character ASCII pattern needs neither the prefilter nor the
|
||||||
|
// score matrix
|
||||||
|
if !disableSingle && M == 1 && input.IsBytes() && pattern[0] < utf8.RuneSelf {
|
||||||
|
return fuzzyMatchV2Single(caseSensitive, forward, input, byte(pattern[0]), withPos)
|
||||||
|
}
|
||||||
|
|
||||||
// Phase 1. Optimized search for ASCII string
|
// Phase 1. Optimized search for ASCII string
|
||||||
minIdx, maxIdx := asciiFuzzyIndex(input, pattern, caseSensitive)
|
minIdx, maxIdx := asciiFuzzyIndex(input, pattern, caseSensitive)
|
||||||
if minIdx < 0 {
|
if minIdx < 0 {
|
||||||
@@ -459,6 +755,13 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
|
|||||||
// fmt.Println(N, maxIdx, idx, maxIdx-idx, input.ToString())
|
// fmt.Println(N, maxIdx, idx, maxIdx-idx, input.ToString())
|
||||||
N = maxIdx - minIdx
|
N = maxIdx - minIdx
|
||||||
|
|
||||||
|
// Two-character ASCII pattern: rows 0 and 1 collapse to scalar running
|
||||||
|
// state, so the general score arrays are unnecessary
|
||||||
|
if !disableTwo && M == 2 && input.IsBytes() &&
|
||||||
|
pattern[0] < utf8.RuneSelf && pattern[1] < utf8.RuneSelf {
|
||||||
|
return fuzzyMatchV2Two(caseSensitive, forward, input, byte(pattern[0]), byte(pattern[1]), minIdx, maxIdx, withPos, slab)
|
||||||
|
}
|
||||||
|
|
||||||
// Reuse pre-allocated integer slice to avoid unnecessary sweeping of garbages
|
// Reuse pre-allocated integer slice to avoid unnecessary sweeping of garbages
|
||||||
offset16 := 0
|
offset16 := 0
|
||||||
offset32 := 0
|
offset32 := 0
|
||||||
@@ -634,6 +937,7 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
|
|||||||
s2 = H[I+j0-1]
|
s2 = H[I+j0-1]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
row := i
|
||||||
if s > s1 && (s > s2 || s == s2 && preferMatch) {
|
if s > s1 && (s > s2 || s == s2 && preferMatch) {
|
||||||
*pos = append(*pos, j+minIdx)
|
*pos = append(*pos, j+minIdx)
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
@@ -641,7 +945,10 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
|
|||||||
}
|
}
|
||||||
i--
|
i--
|
||||||
}
|
}
|
||||||
preferMatch = C[I+j0] > 1 || I+width+j0+1 < len(C) && C[I+width+j0+1] > 0
|
// Row below is only written from column F[row+1]; don't read
|
||||||
|
// stale slab data left of it
|
||||||
|
preferMatch = C[I+j0] > 1 ||
|
||||||
|
row+1 < M && j < lastIdx && int32(j+1) >= F[row+1] && C[I+width+j0+1] > 0
|
||||||
j--
|
j--
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,15 @@ func TestFuzzyMatch(t *testing.T) {
|
|||||||
scoreMatch*4+int(bonusBoundaryDelimiter)*bonusFirstCharMultiplier+int(bonusBoundaryDelimiter)*3)
|
scoreMatch*4+int(bonusBoundaryDelimiter)*bonusFirstCharMultiplier+int(bonusBoundaryDelimiter)*3)
|
||||||
assertMatch(t, fn, false, forward, "/.oh-my-zsh/cache", "zshc", 8, 13,
|
assertMatch(t, fn, false, forward, "/.oh-my-zsh/cache", "zshc", 8, 13,
|
||||||
scoreMatch*4+bonusBoundary*bonusFirstCharMultiplier+bonusBoundary*2+scoreGapStart+int(bonusBoundaryDelimiter))
|
scoreMatch*4+bonusBoundary*bonusFirstCharMultiplier+bonusBoundary*2+scoreGapStart+int(bonusBoundaryDelimiter))
|
||||||
|
// Non-word character at start of input is treated as a strong boundary
|
||||||
|
assertMatch(t, fn, false, forward, ".vimrc", ".vimrc", 0, 6,
|
||||||
|
scoreMatch*6+int(bonusBoundaryWhite)*(bonusFirstCharMultiplier+5))
|
||||||
|
// Non-word character right after a delimiter inherits the delimiter boundary
|
||||||
|
assertMatch(t, fn, false, forward, "/.vimrc", ".vimrc", 1, 7,
|
||||||
|
scoreMatch*6+int(bonusBoundaryDelimiter)*(bonusFirstCharMultiplier+5))
|
||||||
|
// Non-word character in the middle of a word stays at bonusNonWord
|
||||||
|
assertMatch(t, fn, false, forward, "a.vimrc", ".vimrc", 1, 7,
|
||||||
|
scoreMatch*6+bonusBoundary*(bonusFirstCharMultiplier+5))
|
||||||
assertMatch(t, fn, false, forward, "ab0123 456", "12356", 3, 10,
|
assertMatch(t, fn, false, forward, "ab0123 456", "12356", 3, 10,
|
||||||
scoreMatch*5+bonusConsecutive*3+scoreGapStart+scoreGapExtension)
|
scoreMatch*5+bonusConsecutive*3+scoreGapStart+scoreGapExtension)
|
||||||
assertMatch(t, fn, false, forward, "abc123 456", "12356", 3, 10,
|
assertMatch(t, fn, false, forward, "abc123 456", "12356", 3, 10,
|
||||||
@@ -209,3 +218,98 @@ func TestLongStringWithNormalize(t *testing.T) {
|
|||||||
unicodeString := string(bytes) + " Minímal example"
|
unicodeString := string(bytes) + " Minímal example"
|
||||||
assertMatch2(t, FuzzyMatchV1, false, true, false, unicodeString, "minim", 30001, 30006, 140)
|
assertMatch2(t, FuzzyMatchV1, false, true, false, unicodeString, "minim", 30001, 30006, 140)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResultPositionsWithReusedSlab(t *testing.T) {
|
||||||
|
// Backtrace positions in equal-score ties must not depend on data
|
||||||
|
// a previous match left in the slab
|
||||||
|
pattern := []rune("co/")
|
||||||
|
target := util.ToChars([]byte("core_color/view/server.txt"))
|
||||||
|
_, freshPos := FuzzyMatchV2(false, false, true, &target, pattern, true, util.MakeSlab(100*1024, 2048))
|
||||||
|
|
||||||
|
slab := util.MakeSlab(100*1024, 2048)
|
||||||
|
dirty := util.ToChars([]byte("completion/keybinding/client/handler/writer_index.txt"))
|
||||||
|
FuzzyMatchV2(false, false, true, &dirty, pattern, true, slab)
|
||||||
|
_, reusedPos := FuzzyMatchV2(false, false, true, &target, pattern, true, slab)
|
||||||
|
|
||||||
|
if len(*freshPos) != len(*reusedPos) {
|
||||||
|
t.Fatalf("position count mismatch: %v vs %v", *freshPos, *reusedPos)
|
||||||
|
}
|
||||||
|
for i := range *freshPos {
|
||||||
|
if (*freshPos)[i] != (*reusedPos)[i] {
|
||||||
|
t.Errorf("positions differ with reused slab: %v vs %v", *freshPos, *reusedPos)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFuzzyMatchV2TwoEquivalence verifies that the two-character fast path
|
||||||
|
// produces the same Result and positions as the general algorithm across
|
||||||
|
// case sensitivity, direction, and withPos, using a reused slab to surface
|
||||||
|
// any stale-data reads in the backtrace.
|
||||||
|
func TestFuzzyMatchV2TwoEquivalence(t *testing.T) {
|
||||||
|
words := []string{"src", "main", "core", "config", "parser", "render", "server",
|
||||||
|
"client", "index", "handler", "util", "list", "cache", "reader"}
|
||||||
|
exts := []string{".go", ".rb", ".py", ".md", ".c", ".txt"}
|
||||||
|
// Deterministic corpus (LCG), plus adversarial short/repeated items
|
||||||
|
corpus := []util.Chars{}
|
||||||
|
seed := uint32(12345)
|
||||||
|
next := func(n int) int { seed = seed*1664525 + 1013904223; return int(seed>>8) % n }
|
||||||
|
for i := 0; i < 4000; i++ {
|
||||||
|
depth := 2 + next(4)
|
||||||
|
s := ""
|
||||||
|
for d := 0; d < depth; d++ {
|
||||||
|
if d > 0 {
|
||||||
|
s += "/"
|
||||||
|
}
|
||||||
|
s += words[next(len(words))]
|
||||||
|
if next(5) == 0 {
|
||||||
|
s += "_" + words[next(len(words))]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s += exts[next(len(exts))]
|
||||||
|
corpus = append(corpus, util.ToChars([]byte(s)))
|
||||||
|
}
|
||||||
|
for _, s := range []string{"", "a", "ab", "aa", "aXb", "a/b", "//", "..", "abcabc",
|
||||||
|
"AaBb", "x.y.z", "a_b_c", "CoreCore", " co"} {
|
||||||
|
corpus = append(corpus, util.ToChars([]byte(s)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pats := []string{"co", "ab", "aa", "//", "..", "sr", "a/", "_c", "oo", "Ab",
|
||||||
|
"z.", "b.", "1a", "ll", "re", "er", "Co"}
|
||||||
|
slab := util.MakeSlab(100*1024, 2048)
|
||||||
|
for _, cs := range []bool{false, true} {
|
||||||
|
for _, fwd := range []bool{true, false} {
|
||||||
|
for _, wp := range []bool{false, true} {
|
||||||
|
for _, p := range pats {
|
||||||
|
pattern := []rune(p)
|
||||||
|
for j := range corpus {
|
||||||
|
disableTwo = true
|
||||||
|
rg, pg := FuzzyMatchV2(cs, false, fwd, &corpus[j], pattern, wp, slab)
|
||||||
|
disableTwo = false
|
||||||
|
rt, pt := FuzzyMatchV2(cs, false, fwd, &corpus[j], pattern, wp, slab)
|
||||||
|
if rg != rt {
|
||||||
|
t.Fatalf("Result cs=%v fwd=%v wp=%v pat=%q item=%q: general %v vs two %v",
|
||||||
|
cs, fwd, wp, p, corpus[j].ToString(), rg, rt)
|
||||||
|
}
|
||||||
|
if (pg == nil) != (pt == nil) || (pg != nil && !equalInts(*pg, *pt)) {
|
||||||
|
t.Fatalf("Pos cs=%v fwd=%v wp=%v pat=%q item=%q: general %v vs two %v",
|
||||||
|
cs, fwd, wp, p, corpus[j].ToString(), pg, pt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func equalInts(a, b []int) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package algo
|
||||||
|
|
||||||
|
// Equivalence tests for the single- and two-character fast paths against the
|
||||||
|
// general FuzzyMatchV2 algorithm, which serves as the reference.
|
||||||
|
//
|
||||||
|
// Two complementary strategies:
|
||||||
|
// - Exhaustive: every string up to a fixed length over an alphabet that
|
||||||
|
// covers all ASCII character classes, so every local scoring branch is
|
||||||
|
// exercised (not merely sampled).
|
||||||
|
// - Fuzz: coverage-guided, arbitrary length, to reach cases the bounded
|
||||||
|
// exhaustive sweep cannot (e.g. long gaps where a high score decays).
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/junegunn/fzf/src/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
// One char of each ASCII class that affects scoring: lower, upper, delimiter,
|
||||||
|
// number, non-word, whitespace.
|
||||||
|
var equivAlphabet = []byte{'a', 'B', '/', '1', '_', ' '}
|
||||||
|
|
||||||
|
func samePos(a, b *[]int) bool {
|
||||||
|
if (a == nil) != (b == nil) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if a == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return equalInts(*a, *b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// compareFastPath runs a single input/pattern/params pair through both the
|
||||||
|
// fast path and the general algorithm and fails on any difference.
|
||||||
|
// It toggles the package-global disable* hook, so tests that call it must
|
||||||
|
// not run in parallel.
|
||||||
|
func compareFastPath(t *testing.T, chars *util.Chars, pattern []rune, cs, norm, fwd, wp bool, disable *bool, slab *util.Slab) {
|
||||||
|
t.Helper()
|
||||||
|
*disable = true
|
||||||
|
rg, pg := FuzzyMatchV2(cs, norm, fwd, chars, pattern, wp, slab)
|
||||||
|
*disable = false
|
||||||
|
rt, pt := FuzzyMatchV2(cs, norm, fwd, chars, pattern, wp, slab)
|
||||||
|
if rg != rt || !samePos(pg, pt) {
|
||||||
|
t.Fatalf("mismatch item=%q pat=%q cs=%v norm=%v fwd=%v wp=%v: general %v %v vs fast %v %v",
|
||||||
|
chars.ToString(), string(pattern), cs, norm, fwd, wp, rg, pg, rt, pt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// lowerPattern lowercases the pattern for case-insensitive search, matching
|
||||||
|
// the Algo contract (the pattern is pre-lowercased by BuildPattern).
|
||||||
|
func lowerPattern(p []rune, cs bool) []rune {
|
||||||
|
if cs {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
out := make([]rune, len(p))
|
||||||
|
for i, c := range p {
|
||||||
|
if c >= 'A' && c <= 'Z' {
|
||||||
|
c += 32
|
||||||
|
}
|
||||||
|
out[i] = c
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func runExhaustive(t *testing.T, patLen, maxLen int, disable *bool) {
|
||||||
|
// All patterns of length patLen over the alphabet
|
||||||
|
var pats [][]rune
|
||||||
|
var genPat func(cur []rune)
|
||||||
|
genPat = func(cur []rune) {
|
||||||
|
if len(cur) == patLen {
|
||||||
|
pats = append(pats, append([]rune(nil), cur...))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, c := range equivAlphabet {
|
||||||
|
genPat(append(cur, rune(c)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
genPat(nil)
|
||||||
|
|
||||||
|
slab := util.MakeSlab(100*1024, 2048)
|
||||||
|
buf := make([]byte, 0, maxLen)
|
||||||
|
var rec func(depth int)
|
||||||
|
rec = func(depth int) {
|
||||||
|
chars := util.ToChars(append([]byte(nil), buf...))
|
||||||
|
// normalize is not varied: normalizeRune is the identity below 0xC0,
|
||||||
|
// and the general algorithm skips normalization on its ASCII branch,
|
||||||
|
// so it cannot change the result for the ASCII-only fast path. The
|
||||||
|
// fuzz test exercises normalize=true as a guard against that changing.
|
||||||
|
for _, cs := range []bool{false, true} {
|
||||||
|
for _, fwd := range []bool{true, false} {
|
||||||
|
for _, wp := range []bool{false, true} {
|
||||||
|
for _, p := range pats {
|
||||||
|
compareFastPath(t, &chars, lowerPattern(p, cs), cs, false, fwd, wp, disable, slab)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if depth == maxLen {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, c := range equivAlphabet {
|
||||||
|
buf = append(buf, c)
|
||||||
|
rec(depth + 1)
|
||||||
|
buf = buf[:len(buf)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rec(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFuzzyMatchV2SingleExhaustive(t *testing.T) {
|
||||||
|
runExhaustive(t, 1, 6, &disableSingle)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFuzzyMatchV2TwoExhaustive(t *testing.T) {
|
||||||
|
runExhaustive(t, 2, 6, &disableTwo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fuzzFastPath(f *testing.F, patLen int, disable *bool) {
|
||||||
|
inputs := []string{"core_color/view/server.txt", "a b",
|
||||||
|
"XyZ/123_abc.def", "aaaaaaaaaaaaaaaaaaaaaaaa"}
|
||||||
|
// Seed patterns of the right length; wrong-length seeds are rejected below
|
||||||
|
// and would waste the corpus.
|
||||||
|
seedPats := map[int][]string{1: {"c", "a", "/"}, 2: {"co", "ab", "z1", "aa"}}[patLen]
|
||||||
|
for _, in := range inputs {
|
||||||
|
for _, p := range seedPats {
|
||||||
|
f.Add(in, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slab := util.MakeSlab(200*1024, 4096)
|
||||||
|
f.Fuzz(func(t *testing.T, input, pat string) {
|
||||||
|
r := []rune(pat)
|
||||||
|
if len(r) != patLen {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, c := range r {
|
||||||
|
if c >= 128 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chars := util.ToChars([]byte(input))
|
||||||
|
if !chars.IsBytes() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, cs := range []bool{false, true} {
|
||||||
|
for _, norm := range []bool{false, true} {
|
||||||
|
for _, fwd := range []bool{true, false} {
|
||||||
|
for _, wp := range []bool{false, true} {
|
||||||
|
compareFastPath(t, &chars, lowerPattern(r, cs), cs, norm, fwd, wp, disable, slab)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func FuzzFuzzyMatchV2Single(f *testing.F) { fuzzFastPath(f, 1, &disableSingle) }
|
||||||
|
func FuzzFuzzyMatchV2Two(f *testing.F) { fuzzFastPath(f, 2, &disableTwo) }
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
//go:build amd64
|
||||||
|
|
||||||
|
package algo
|
||||||
|
|
||||||
|
var _useAVX2 bool
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
_useAVX2 = cpuHasAVX2()
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:noescape
|
||||||
|
func cpuHasAVX2() bool
|
||||||
|
|
||||||
|
// indexByteTwo returns the index of the first occurrence of b1 or b2 in s,
|
||||||
|
// or -1 if neither is present. Uses AVX2 when available, SSE2 otherwise.
|
||||||
|
//
|
||||||
|
//go:noescape
|
||||||
|
func IndexByteTwo(s []byte, b1, b2 byte) int
|
||||||
|
|
||||||
|
// lastIndexByteTwo returns the index of the last occurrence of b1 or b2 in s,
|
||||||
|
// or -1 if neither is present. Uses AVX2 when available, SSE2 otherwise.
|
||||||
|
//
|
||||||
|
//go:noescape
|
||||||
|
func lastIndexByteTwo(s []byte, b1, b2 byte) int
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
#include "textflag.h"
|
||||||
|
|
||||||
|
// func cpuHasAVX2() bool
|
||||||
|
//
|
||||||
|
// Checks CPUID and XGETBV for AVX2 + OS YMM support.
|
||||||
|
TEXT ·cpuHasAVX2(SB),NOSPLIT,$0-1
|
||||||
|
MOVQ BX, R8 // save BX (callee-saved, clobbered by CPUID)
|
||||||
|
|
||||||
|
// Check max CPUID leaf >= 7
|
||||||
|
MOVL $0, AX
|
||||||
|
CPUID
|
||||||
|
CMPL AX, $7
|
||||||
|
JL cpuid_no
|
||||||
|
|
||||||
|
// Check OSXSAVE (CPUID.1:ECX bit 27)
|
||||||
|
MOVL $1, AX
|
||||||
|
CPUID
|
||||||
|
TESTL $(1<<27), CX
|
||||||
|
JZ cpuid_no
|
||||||
|
|
||||||
|
// Check AVX2 (CPUID.7.0:EBX bit 5)
|
||||||
|
MOVL $7, AX
|
||||||
|
MOVL $0, CX
|
||||||
|
CPUID
|
||||||
|
TESTL $(1<<5), BX
|
||||||
|
JZ cpuid_no
|
||||||
|
|
||||||
|
// Check OS YMM state support via XGETBV
|
||||||
|
MOVL $0, CX
|
||||||
|
BYTE $0x0F; BYTE $0x01; BYTE $0xD0 // XGETBV → EDX:EAX
|
||||||
|
ANDL $6, AX // bits 1 (XMM) and 2 (YMM)
|
||||||
|
CMPL AX, $6
|
||||||
|
JNE cpuid_no
|
||||||
|
|
||||||
|
MOVQ R8, BX // restore BX
|
||||||
|
MOVB $1, ret+0(FP)
|
||||||
|
RET
|
||||||
|
|
||||||
|
cpuid_no:
|
||||||
|
MOVQ R8, BX
|
||||||
|
MOVB $0, ret+0(FP)
|
||||||
|
RET
|
||||||
|
|
||||||
|
// func IndexByteTwo(s []byte, b1, b2 byte) int
|
||||||
|
//
|
||||||
|
// Returns the index of the first occurrence of b1 or b2 in s, or -1.
|
||||||
|
// Uses AVX2 (32 bytes/iter) when available, SSE2 (16 bytes/iter) otherwise.
|
||||||
|
TEXT ·IndexByteTwo(SB),NOSPLIT,$0-40
|
||||||
|
MOVQ s_base+0(FP), SI
|
||||||
|
MOVQ s_len+8(FP), BX
|
||||||
|
MOVBLZX b1+24(FP), AX
|
||||||
|
MOVBLZX b2+25(FP), CX
|
||||||
|
LEAQ ret+32(FP), R8
|
||||||
|
|
||||||
|
TESTQ BX, BX
|
||||||
|
JEQ fwd_failure
|
||||||
|
|
||||||
|
// Try AVX2 for inputs >= 32 bytes
|
||||||
|
CMPQ BX, $32
|
||||||
|
JLT fwd_sse2
|
||||||
|
CMPB ·_useAVX2(SB), $1
|
||||||
|
JNE fwd_sse2
|
||||||
|
|
||||||
|
// ====== AVX2 forward search ======
|
||||||
|
MOVD AX, X0
|
||||||
|
VPBROADCASTB X0, Y0 // Y0 = splat(b1)
|
||||||
|
MOVD CX, X1
|
||||||
|
VPBROADCASTB X1, Y1 // Y1 = splat(b2)
|
||||||
|
|
||||||
|
MOVQ SI, DI
|
||||||
|
LEAQ -32(SI)(BX*1), AX // AX = last valid 32-byte chunk
|
||||||
|
JMP fwd_avx2_entry
|
||||||
|
|
||||||
|
fwd_avx2_loop:
|
||||||
|
VMOVDQU (DI), Y2
|
||||||
|
VPCMPEQB Y0, Y2, Y3
|
||||||
|
VPCMPEQB Y1, Y2, Y4
|
||||||
|
VPOR Y3, Y4, Y3
|
||||||
|
VPMOVMSKB Y3, DX
|
||||||
|
BSFL DX, DX
|
||||||
|
JNZ fwd_avx2_success
|
||||||
|
ADDQ $32, DI
|
||||||
|
|
||||||
|
fwd_avx2_entry:
|
||||||
|
CMPQ DI, AX
|
||||||
|
JB fwd_avx2_loop
|
||||||
|
|
||||||
|
// Last 32-byte chunk (may overlap with previous)
|
||||||
|
MOVQ AX, DI
|
||||||
|
VMOVDQU (AX), Y2
|
||||||
|
VPCMPEQB Y0, Y2, Y3
|
||||||
|
VPCMPEQB Y1, Y2, Y4
|
||||||
|
VPOR Y3, Y4, Y3
|
||||||
|
VPMOVMSKB Y3, DX
|
||||||
|
BSFL DX, DX
|
||||||
|
JNZ fwd_avx2_success
|
||||||
|
|
||||||
|
MOVQ $-1, (R8)
|
||||||
|
VZEROUPPER
|
||||||
|
RET
|
||||||
|
|
||||||
|
fwd_avx2_success:
|
||||||
|
SUBQ SI, DI
|
||||||
|
ADDQ DX, DI
|
||||||
|
MOVQ DI, (R8)
|
||||||
|
VZEROUPPER
|
||||||
|
RET
|
||||||
|
|
||||||
|
// ====== SSE2 forward search (< 32 bytes or no AVX2) ======
|
||||||
|
|
||||||
|
fwd_sse2:
|
||||||
|
// Broadcast b1 into X0
|
||||||
|
MOVD AX, X0
|
||||||
|
PUNPCKLBW X0, X0
|
||||||
|
PUNPCKLBW X0, X0
|
||||||
|
PSHUFL $0, X0, X0
|
||||||
|
|
||||||
|
// Broadcast b2 into X4
|
||||||
|
MOVD CX, X4
|
||||||
|
PUNPCKLBW X4, X4
|
||||||
|
PUNPCKLBW X4, X4
|
||||||
|
PSHUFL $0, X4, X4
|
||||||
|
|
||||||
|
CMPQ BX, $16
|
||||||
|
JLT fwd_small
|
||||||
|
|
||||||
|
MOVQ SI, DI
|
||||||
|
LEAQ -16(SI)(BX*1), AX
|
||||||
|
JMP fwd_sseloopentry
|
||||||
|
|
||||||
|
fwd_sseloop:
|
||||||
|
MOVOU (DI), X1
|
||||||
|
MOVOU X1, X2
|
||||||
|
PCMPEQB X0, X1
|
||||||
|
PCMPEQB X4, X2
|
||||||
|
POR X2, X1
|
||||||
|
PMOVMSKB X1, DX
|
||||||
|
BSFL DX, DX
|
||||||
|
JNZ fwd_ssesuccess
|
||||||
|
ADDQ $16, DI
|
||||||
|
|
||||||
|
fwd_sseloopentry:
|
||||||
|
CMPQ DI, AX
|
||||||
|
JB fwd_sseloop
|
||||||
|
|
||||||
|
// Search the last 16-byte chunk (may overlap)
|
||||||
|
MOVQ AX, DI
|
||||||
|
MOVOU (AX), X1
|
||||||
|
MOVOU X1, X2
|
||||||
|
PCMPEQB X0, X1
|
||||||
|
PCMPEQB X4, X2
|
||||||
|
POR X2, X1
|
||||||
|
PMOVMSKB X1, DX
|
||||||
|
BSFL DX, DX
|
||||||
|
JNZ fwd_ssesuccess
|
||||||
|
|
||||||
|
fwd_failure:
|
||||||
|
MOVQ $-1, (R8)
|
||||||
|
RET
|
||||||
|
|
||||||
|
fwd_ssesuccess:
|
||||||
|
SUBQ SI, DI
|
||||||
|
ADDQ DX, DI
|
||||||
|
MOVQ DI, (R8)
|
||||||
|
RET
|
||||||
|
|
||||||
|
fwd_small:
|
||||||
|
// Check if loading 16 bytes from SI would cross a page boundary
|
||||||
|
LEAQ 16(SI), AX
|
||||||
|
TESTW $0xff0, AX
|
||||||
|
JEQ fwd_endofpage
|
||||||
|
|
||||||
|
MOVOU (SI), X1
|
||||||
|
MOVOU X1, X2
|
||||||
|
PCMPEQB X0, X1
|
||||||
|
PCMPEQB X4, X2
|
||||||
|
POR X2, X1
|
||||||
|
PMOVMSKB X1, DX
|
||||||
|
BSFL DX, DX
|
||||||
|
JZ fwd_failure
|
||||||
|
CMPL DX, BX
|
||||||
|
JAE fwd_failure
|
||||||
|
MOVQ DX, (R8)
|
||||||
|
RET
|
||||||
|
|
||||||
|
fwd_endofpage:
|
||||||
|
MOVOU -16(SI)(BX*1), X1
|
||||||
|
MOVOU X1, X2
|
||||||
|
PCMPEQB X0, X1
|
||||||
|
PCMPEQB X4, X2
|
||||||
|
POR X2, X1
|
||||||
|
PMOVMSKB X1, DX
|
||||||
|
MOVL BX, CX
|
||||||
|
SHLL CX, DX
|
||||||
|
SHRL $16, DX
|
||||||
|
BSFL DX, DX
|
||||||
|
JZ fwd_failure
|
||||||
|
MOVQ DX, (R8)
|
||||||
|
RET
|
||||||
|
|
||||||
|
// func lastIndexByteTwo(s []byte, b1, b2 byte) int
|
||||||
|
//
|
||||||
|
// Returns the index of the last occurrence of b1 or b2 in s, or -1.
|
||||||
|
// Uses AVX2 (32 bytes/iter) when available, SSE2 (16 bytes/iter) otherwise.
|
||||||
|
TEXT ·lastIndexByteTwo(SB),NOSPLIT,$0-40
|
||||||
|
MOVQ s_base+0(FP), SI
|
||||||
|
MOVQ s_len+8(FP), BX
|
||||||
|
MOVBLZX b1+24(FP), AX
|
||||||
|
MOVBLZX b2+25(FP), CX
|
||||||
|
LEAQ ret+32(FP), R8
|
||||||
|
|
||||||
|
TESTQ BX, BX
|
||||||
|
JEQ back_failure
|
||||||
|
|
||||||
|
// Try AVX2 for inputs >= 32 bytes
|
||||||
|
CMPQ BX, $32
|
||||||
|
JLT back_sse2
|
||||||
|
CMPB ·_useAVX2(SB), $1
|
||||||
|
JNE back_sse2
|
||||||
|
|
||||||
|
// ====== AVX2 backward search ======
|
||||||
|
MOVD AX, X0
|
||||||
|
VPBROADCASTB X0, Y0
|
||||||
|
MOVD CX, X1
|
||||||
|
VPBROADCASTB X1, Y1
|
||||||
|
|
||||||
|
// DI = start of last 32-byte chunk
|
||||||
|
LEAQ -32(SI)(BX*1), DI
|
||||||
|
|
||||||
|
back_avx2_loop:
|
||||||
|
CMPQ DI, SI
|
||||||
|
JBE back_avx2_first
|
||||||
|
|
||||||
|
VMOVDQU (DI), Y2
|
||||||
|
VPCMPEQB Y0, Y2, Y3
|
||||||
|
VPCMPEQB Y1, Y2, Y4
|
||||||
|
VPOR Y3, Y4, Y3
|
||||||
|
VPMOVMSKB Y3, DX
|
||||||
|
BSRL DX, DX
|
||||||
|
JNZ back_avx2_success
|
||||||
|
SUBQ $32, DI
|
||||||
|
JMP back_avx2_loop
|
||||||
|
|
||||||
|
back_avx2_first:
|
||||||
|
// First 32 bytes (DI <= SI, load from SI)
|
||||||
|
VMOVDQU (SI), Y2
|
||||||
|
VPCMPEQB Y0, Y2, Y3
|
||||||
|
VPCMPEQB Y1, Y2, Y4
|
||||||
|
VPOR Y3, Y4, Y3
|
||||||
|
VPMOVMSKB Y3, DX
|
||||||
|
BSRL DX, DX
|
||||||
|
JNZ back_avx2_firstsuccess
|
||||||
|
|
||||||
|
MOVQ $-1, (R8)
|
||||||
|
VZEROUPPER
|
||||||
|
RET
|
||||||
|
|
||||||
|
back_avx2_success:
|
||||||
|
SUBQ SI, DI
|
||||||
|
ADDQ DX, DI
|
||||||
|
MOVQ DI, (R8)
|
||||||
|
VZEROUPPER
|
||||||
|
RET
|
||||||
|
|
||||||
|
back_avx2_firstsuccess:
|
||||||
|
MOVQ DX, (R8)
|
||||||
|
VZEROUPPER
|
||||||
|
RET
|
||||||
|
|
||||||
|
// ====== SSE2 backward search (< 32 bytes or no AVX2) ======
|
||||||
|
|
||||||
|
back_sse2:
|
||||||
|
// Broadcast b1 into X0
|
||||||
|
MOVD AX, X0
|
||||||
|
PUNPCKLBW X0, X0
|
||||||
|
PUNPCKLBW X0, X0
|
||||||
|
PSHUFL $0, X0, X0
|
||||||
|
|
||||||
|
// Broadcast b2 into X4
|
||||||
|
MOVD CX, X4
|
||||||
|
PUNPCKLBW X4, X4
|
||||||
|
PUNPCKLBW X4, X4
|
||||||
|
PSHUFL $0, X4, X4
|
||||||
|
|
||||||
|
CMPQ BX, $16
|
||||||
|
JLT back_small
|
||||||
|
|
||||||
|
// DI = start of last 16-byte chunk
|
||||||
|
LEAQ -16(SI)(BX*1), DI
|
||||||
|
|
||||||
|
back_sseloop:
|
||||||
|
CMPQ DI, SI
|
||||||
|
JBE back_ssefirst
|
||||||
|
|
||||||
|
MOVOU (DI), X1
|
||||||
|
MOVOU X1, X2
|
||||||
|
PCMPEQB X0, X1
|
||||||
|
PCMPEQB X4, X2
|
||||||
|
POR X2, X1
|
||||||
|
PMOVMSKB X1, DX
|
||||||
|
BSRL DX, DX
|
||||||
|
JNZ back_ssesuccess
|
||||||
|
SUBQ $16, DI
|
||||||
|
JMP back_sseloop
|
||||||
|
|
||||||
|
back_ssefirst:
|
||||||
|
// First 16 bytes (DI <= SI, load from SI)
|
||||||
|
MOVOU (SI), X1
|
||||||
|
MOVOU X1, X2
|
||||||
|
PCMPEQB X0, X1
|
||||||
|
PCMPEQB X4, X2
|
||||||
|
POR X2, X1
|
||||||
|
PMOVMSKB X1, DX
|
||||||
|
BSRL DX, DX
|
||||||
|
JNZ back_ssefirstsuccess
|
||||||
|
|
||||||
|
back_failure:
|
||||||
|
MOVQ $-1, (R8)
|
||||||
|
RET
|
||||||
|
|
||||||
|
back_ssesuccess:
|
||||||
|
SUBQ SI, DI
|
||||||
|
ADDQ DX, DI
|
||||||
|
MOVQ DI, (R8)
|
||||||
|
RET
|
||||||
|
|
||||||
|
back_ssefirstsuccess:
|
||||||
|
// DX = byte offset from base
|
||||||
|
MOVQ DX, (R8)
|
||||||
|
RET
|
||||||
|
|
||||||
|
back_small:
|
||||||
|
// Check page boundary
|
||||||
|
LEAQ 16(SI), AX
|
||||||
|
TESTW $0xff0, AX
|
||||||
|
JEQ back_endofpage
|
||||||
|
|
||||||
|
MOVOU (SI), X1
|
||||||
|
MOVOU X1, X2
|
||||||
|
PCMPEQB X0, X1
|
||||||
|
PCMPEQB X4, X2
|
||||||
|
POR X2, X1
|
||||||
|
PMOVMSKB X1, DX
|
||||||
|
// Mask to first BX bytes: keep bits 0..BX-1
|
||||||
|
MOVL $1, AX
|
||||||
|
MOVL BX, CX
|
||||||
|
SHLL CX, AX
|
||||||
|
DECL AX
|
||||||
|
ANDL AX, DX
|
||||||
|
BSRL DX, DX
|
||||||
|
JZ back_failure
|
||||||
|
MOVQ DX, (R8)
|
||||||
|
RET
|
||||||
|
|
||||||
|
back_endofpage:
|
||||||
|
// Load 16 bytes ending at base+n
|
||||||
|
MOVOU -16(SI)(BX*1), X1
|
||||||
|
MOVOU X1, X2
|
||||||
|
PCMPEQB X0, X1
|
||||||
|
PCMPEQB X4, X2
|
||||||
|
POR X2, X1
|
||||||
|
PMOVMSKB X1, DX
|
||||||
|
// Bits correspond to bytes [base+n-16, base+n).
|
||||||
|
// We want original bytes [0, n), which are bits [16-n, 16).
|
||||||
|
// Mask: keep bits (16-n) through 15.
|
||||||
|
MOVL $16, CX
|
||||||
|
SUBL BX, CX
|
||||||
|
SHRL CX, DX
|
||||||
|
SHLL CX, DX
|
||||||
|
BSRL DX, DX
|
||||||
|
JZ back_failure
|
||||||
|
// DX is the bit position in the loaded chunk.
|
||||||
|
// Original byte index = DX - (16 - n) = DX + n - 16
|
||||||
|
ADDL BX, DX
|
||||||
|
SUBL $16, DX
|
||||||
|
MOVQ DX, (R8)
|
||||||
|
RET
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
//go:build arm64
|
||||||
|
|
||||||
|
package algo
|
||||||
|
|
||||||
|
// indexByteTwo returns the index of the first occurrence of b1 or b2 in s,
|
||||||
|
// or -1 if neither is present. Implemented in assembly using ARM64 NEON
|
||||||
|
// to search for both bytes in a single pass.
|
||||||
|
//
|
||||||
|
//go:noescape
|
||||||
|
func IndexByteTwo(s []byte, b1, b2 byte) int
|
||||||
|
|
||||||
|
// lastIndexByteTwo returns the index of the last occurrence of b1 or b2 in s,
|
||||||
|
// or -1 if neither is present. Implemented in assembly using ARM64 NEON,
|
||||||
|
// scanning backward.
|
||||||
|
//
|
||||||
|
//go:noescape
|
||||||
|
func lastIndexByteTwo(s []byte, b1, b2 byte) int
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
#include "textflag.h"
|
||||||
|
|
||||||
|
// func IndexByteTwo(s []byte, b1, b2 byte) int
|
||||||
|
//
|
||||||
|
// Returns the index of the first occurrence of b1 or b2 in s, or -1.
|
||||||
|
// Uses ARM64 NEON to search for both bytes in a single pass over the data.
|
||||||
|
// Adapted from Go's internal/bytealg/indexbyte_arm64.s (single-byte version).
|
||||||
|
TEXT ·IndexByteTwo(SB),NOSPLIT,$0-40
|
||||||
|
MOVD s_base+0(FP), R0
|
||||||
|
MOVD s_len+8(FP), R2
|
||||||
|
MOVBU b1+24(FP), R1
|
||||||
|
MOVBU b2+25(FP), R7
|
||||||
|
MOVD $ret+32(FP), R8
|
||||||
|
|
||||||
|
// Core algorithm:
|
||||||
|
// For each 32-byte chunk we calculate a 64-bit syndrome value,
|
||||||
|
// with two bits per byte. We compare against both b1 and b2,
|
||||||
|
// OR the results, then use the same syndrome extraction as
|
||||||
|
// Go's IndexByte.
|
||||||
|
|
||||||
|
CBZ R2, fail
|
||||||
|
MOVD R0, R11
|
||||||
|
// Magic constant 0x40100401 allows us to identify which lane matches.
|
||||||
|
// Each byte in the group of 4 gets a distinct bit: 1, 4, 16, 64.
|
||||||
|
MOVD $0x40100401, R5
|
||||||
|
VMOV R1, V0.B16 // V0 = splat(b1)
|
||||||
|
VMOV R7, V7.B16 // V7 = splat(b2)
|
||||||
|
// Work with aligned 32-byte chunks
|
||||||
|
BIC $0x1f, R0, R3
|
||||||
|
VMOV R5, V5.S4
|
||||||
|
ANDS $0x1f, R0, R9
|
||||||
|
AND $0x1f, R2, R10
|
||||||
|
BEQ loop
|
||||||
|
|
||||||
|
// Input string is not 32-byte aligned. Process the first
|
||||||
|
// aligned 32-byte block and mask off bytes before our start.
|
||||||
|
VLD1.P (R3), [V1.B16, V2.B16]
|
||||||
|
SUB $0x20, R9, R4
|
||||||
|
ADDS R4, R2, R2
|
||||||
|
// Compare against both needles
|
||||||
|
VCMEQ V0.B16, V1.B16, V3.B16 // b1 vs first 16 bytes
|
||||||
|
VCMEQ V7.B16, V1.B16, V8.B16 // b2 vs first 16 bytes
|
||||||
|
VORR V8.B16, V3.B16, V3.B16 // combine
|
||||||
|
VCMEQ V0.B16, V2.B16, V4.B16 // b1 vs second 16 bytes
|
||||||
|
VCMEQ V7.B16, V2.B16, V9.B16 // b2 vs second 16 bytes
|
||||||
|
VORR V9.B16, V4.B16, V4.B16 // combine
|
||||||
|
// Build syndrome
|
||||||
|
VAND V5.B16, V3.B16, V3.B16
|
||||||
|
VAND V5.B16, V4.B16, V4.B16
|
||||||
|
VADDP V4.B16, V3.B16, V6.B16
|
||||||
|
VADDP V6.B16, V6.B16, V6.B16
|
||||||
|
VMOV V6.D[0], R6
|
||||||
|
// Clear the irrelevant lower bits
|
||||||
|
LSL $1, R9, R4
|
||||||
|
LSR R4, R6, R6
|
||||||
|
LSL R4, R6, R6
|
||||||
|
// The first block can also be the last
|
||||||
|
BLS masklast
|
||||||
|
// Have we found something already?
|
||||||
|
CBNZ R6, tail
|
||||||
|
|
||||||
|
loop:
|
||||||
|
VLD1.P (R3), [V1.B16, V2.B16]
|
||||||
|
SUBS $0x20, R2, R2
|
||||||
|
// Compare against both needles, OR results
|
||||||
|
VCMEQ V0.B16, V1.B16, V3.B16
|
||||||
|
VCMEQ V7.B16, V1.B16, V8.B16
|
||||||
|
VORR V8.B16, V3.B16, V3.B16
|
||||||
|
VCMEQ V0.B16, V2.B16, V4.B16
|
||||||
|
VCMEQ V7.B16, V2.B16, V9.B16
|
||||||
|
VORR V9.B16, V4.B16, V4.B16
|
||||||
|
// If we're out of data we finish regardless of the result
|
||||||
|
BLS end
|
||||||
|
// Fast check: OR both halves and check for any match
|
||||||
|
VORR V4.B16, V3.B16, V6.B16
|
||||||
|
VADDP V6.D2, V6.D2, V6.D2
|
||||||
|
VMOV V6.D[0], R6
|
||||||
|
CBZ R6, loop
|
||||||
|
|
||||||
|
end:
|
||||||
|
// Found something or out of data, build full syndrome
|
||||||
|
VAND V5.B16, V3.B16, V3.B16
|
||||||
|
VAND V5.B16, V4.B16, V4.B16
|
||||||
|
VADDP V4.B16, V3.B16, V6.B16
|
||||||
|
VADDP V6.B16, V6.B16, V6.B16
|
||||||
|
VMOV V6.D[0], R6
|
||||||
|
// Only mask for the last block
|
||||||
|
BHS tail
|
||||||
|
|
||||||
|
masklast:
|
||||||
|
// Clear irrelevant upper bits
|
||||||
|
ADD R9, R10, R4
|
||||||
|
AND $0x1f, R4, R4
|
||||||
|
SUB $0x20, R4, R4
|
||||||
|
NEG R4<<1, R4
|
||||||
|
LSL R4, R6, R6
|
||||||
|
LSR R4, R6, R6
|
||||||
|
|
||||||
|
tail:
|
||||||
|
CBZ R6, fail
|
||||||
|
RBIT R6, R6
|
||||||
|
SUB $0x20, R3, R3
|
||||||
|
CLZ R6, R6
|
||||||
|
ADD R6>>1, R3, R0
|
||||||
|
SUB R11, R0, R0
|
||||||
|
MOVD R0, (R8)
|
||||||
|
RET
|
||||||
|
|
||||||
|
fail:
|
||||||
|
MOVD $-1, R0
|
||||||
|
MOVD R0, (R8)
|
||||||
|
RET
|
||||||
|
|
||||||
|
// func lastIndexByteTwo(s []byte, b1, b2 byte) int
|
||||||
|
//
|
||||||
|
// Returns the index of the last occurrence of b1 or b2 in s, or -1.
|
||||||
|
// Scans backward using ARM64 NEON.
|
||||||
|
TEXT ·lastIndexByteTwo(SB),NOSPLIT,$0-40
|
||||||
|
MOVD s_base+0(FP), R0
|
||||||
|
MOVD s_len+8(FP), R2
|
||||||
|
MOVBU b1+24(FP), R1
|
||||||
|
MOVBU b2+25(FP), R7
|
||||||
|
MOVD $ret+32(FP), R8
|
||||||
|
|
||||||
|
CBZ R2, lfail
|
||||||
|
MOVD R0, R11 // save base
|
||||||
|
ADD R0, R2, R12 // R12 = end = base + len
|
||||||
|
MOVD $0x40100401, R5
|
||||||
|
VMOV R1, V0.B16 // V0 = splat(b1)
|
||||||
|
VMOV R7, V7.B16 // V7 = splat(b2)
|
||||||
|
VMOV R5, V5.S4
|
||||||
|
|
||||||
|
// Align: find the aligned block containing the last byte
|
||||||
|
SUB $1, R12, R3
|
||||||
|
BIC $0x1f, R3, R3 // R3 = start of aligned block containing last byte
|
||||||
|
|
||||||
|
// --- Process tail block ---
|
||||||
|
VLD1 (R3), [V1.B16, V2.B16]
|
||||||
|
VCMEQ V0.B16, V1.B16, V3.B16
|
||||||
|
VCMEQ V7.B16, V1.B16, V8.B16
|
||||||
|
VORR V8.B16, V3.B16, V3.B16
|
||||||
|
VCMEQ V0.B16, V2.B16, V4.B16
|
||||||
|
VCMEQ V7.B16, V2.B16, V9.B16
|
||||||
|
VORR V9.B16, V4.B16, V4.B16
|
||||||
|
VAND V5.B16, V3.B16, V3.B16
|
||||||
|
VAND V5.B16, V4.B16, V4.B16
|
||||||
|
VADDP V4.B16, V3.B16, V6.B16
|
||||||
|
VADDP V6.B16, V6.B16, V6.B16
|
||||||
|
VMOV V6.D[0], R6
|
||||||
|
|
||||||
|
// Mask upper bits (bytes past end of slice)
|
||||||
|
// tail_bytes = end - R3 (1..32)
|
||||||
|
SUB R3, R12, R10 // R10 = tail_bytes
|
||||||
|
MOVD $64, R4
|
||||||
|
SUB R10<<1, R4, R4 // R4 = 64 - 2*tail_bytes
|
||||||
|
LSL R4, R6, R6
|
||||||
|
LSR R4, R6, R6
|
||||||
|
|
||||||
|
// Is this also the head block?
|
||||||
|
CMP R11, R3 // R3 - R11
|
||||||
|
BLO lmaskfirst // R3 < base: head+tail in same block
|
||||||
|
BEQ ltailonly // R3 == base: single aligned block
|
||||||
|
|
||||||
|
// R3 > base: more blocks before this one
|
||||||
|
CBNZ R6, llast
|
||||||
|
B lbacksetup
|
||||||
|
|
||||||
|
ltailonly:
|
||||||
|
// Single block, already masked upper bits
|
||||||
|
CBNZ R6, llast
|
||||||
|
B lfail
|
||||||
|
|
||||||
|
lmaskfirst:
|
||||||
|
// Mask lower bits (bytes before start of slice)
|
||||||
|
SUB R3, R11, R4 // R4 = base - R3
|
||||||
|
LSL $1, R4, R4
|
||||||
|
LSR R4, R6, R6
|
||||||
|
LSL R4, R6, R6
|
||||||
|
CBNZ R6, llast
|
||||||
|
B lfail
|
||||||
|
|
||||||
|
lbacksetup:
|
||||||
|
SUB $0x20, R3
|
||||||
|
|
||||||
|
lbackloop:
|
||||||
|
VLD1 (R3), [V1.B16, V2.B16]
|
||||||
|
VCMEQ V0.B16, V1.B16, V3.B16
|
||||||
|
VCMEQ V7.B16, V1.B16, V8.B16
|
||||||
|
VORR V8.B16, V3.B16, V3.B16
|
||||||
|
VCMEQ V0.B16, V2.B16, V4.B16
|
||||||
|
VCMEQ V7.B16, V2.B16, V9.B16
|
||||||
|
VORR V9.B16, V4.B16, V4.B16
|
||||||
|
// Quick check: any match in this block?
|
||||||
|
VORR V4.B16, V3.B16, V6.B16
|
||||||
|
VADDP V6.D2, V6.D2, V6.D2
|
||||||
|
VMOV V6.D[0], R6
|
||||||
|
|
||||||
|
// Is this a head block? (R3 < base)
|
||||||
|
CMP R11, R3
|
||||||
|
BLO lheadblock
|
||||||
|
|
||||||
|
// Full block (R3 >= base)
|
||||||
|
CBNZ R6, lbackfound
|
||||||
|
// More blocks?
|
||||||
|
BEQ lfail // R3 == base, no more
|
||||||
|
SUB $0x20, R3
|
||||||
|
B lbackloop
|
||||||
|
|
||||||
|
lbackfound:
|
||||||
|
// Build full syndrome
|
||||||
|
VAND V5.B16, V3.B16, V3.B16
|
||||||
|
VAND V5.B16, V4.B16, V4.B16
|
||||||
|
VADDP V4.B16, V3.B16, V6.B16
|
||||||
|
VADDP V6.B16, V6.B16, V6.B16
|
||||||
|
VMOV V6.D[0], R6
|
||||||
|
B llast
|
||||||
|
|
||||||
|
lheadblock:
|
||||||
|
// R3 < base. Build full syndrome if quick check had a match.
|
||||||
|
CBZ R6, lfail
|
||||||
|
VAND V5.B16, V3.B16, V3.B16
|
||||||
|
VAND V5.B16, V4.B16, V4.B16
|
||||||
|
VADDP V4.B16, V3.B16, V6.B16
|
||||||
|
VADDP V6.B16, V6.B16, V6.B16
|
||||||
|
VMOV V6.D[0], R6
|
||||||
|
// Mask lower bits
|
||||||
|
SUB R3, R11, R4 // R4 = base - R3
|
||||||
|
LSL $1, R4, R4
|
||||||
|
LSR R4, R6, R6
|
||||||
|
LSL R4, R6, R6
|
||||||
|
CBZ R6, lfail
|
||||||
|
|
||||||
|
llast:
|
||||||
|
// Find last match: highest set bit in syndrome
|
||||||
|
// Syndrome has bit 2i set for matching byte i.
|
||||||
|
// CLZ gives leading zeros; byte_offset = (63 - CLZ) / 2.
|
||||||
|
CLZ R6, R6
|
||||||
|
MOVD $63, R4
|
||||||
|
SUB R6, R4, R6 // R6 = 63 - CLZ = bit position
|
||||||
|
LSR $1, R6 // R6 = byte offset within block
|
||||||
|
ADD R3, R6, R0 // R0 = absolute address
|
||||||
|
SUB R11, R0, R0 // R0 = slice index
|
||||||
|
MOVD R0, (R8)
|
||||||
|
RET
|
||||||
|
|
||||||
|
lfail:
|
||||||
|
MOVD $-1, R0
|
||||||
|
MOVD R0, (R8)
|
||||||
|
RET
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
//go:build !arm64 && !amd64
|
||||||
|
|
||||||
|
package algo
|
||||||
|
|
||||||
|
import "bytes"
|
||||||
|
|
||||||
|
// indexByteTwo returns the index of the first occurrence of b1 or b2 in s,
|
||||||
|
// or -1 if neither is present.
|
||||||
|
func IndexByteTwo(s []byte, b1, b2 byte) int {
|
||||||
|
i1 := bytes.IndexByte(s, b1)
|
||||||
|
if i1 == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
scope := s
|
||||||
|
if i1 > 0 {
|
||||||
|
scope = s[:i1]
|
||||||
|
}
|
||||||
|
if i2 := bytes.IndexByte(scope, b2); i2 >= 0 {
|
||||||
|
return i2
|
||||||
|
}
|
||||||
|
return i1
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastIndexByteTwo returns the index of the last occurrence of b1 or b2 in s,
|
||||||
|
// or -1 if neither is present.
|
||||||
|
func lastIndexByteTwo(s []byte, b1, b2 byte) int {
|
||||||
|
for i := len(s) - 1; i >= 0; i-- {
|
||||||
|
if s[i] == b1 || s[i] == b2 {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
package algo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIndexByteTwo(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
s string
|
||||||
|
b1 byte
|
||||||
|
b2 byte
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"empty", "", 'a', 'b', -1},
|
||||||
|
{"single_b1", "a", 'a', 'b', 0},
|
||||||
|
{"single_b2", "b", 'a', 'b', 0},
|
||||||
|
{"single_none", "c", 'a', 'b', -1},
|
||||||
|
{"b1_first", "xaxb", 'a', 'b', 1},
|
||||||
|
{"b2_first", "xbxa", 'a', 'b', 1},
|
||||||
|
{"same_byte", "xxa", 'a', 'a', 2},
|
||||||
|
{"at_end", "xxxxa", 'a', 'b', 4},
|
||||||
|
{"not_found", "xxxxxxxx", 'a', 'b', -1},
|
||||||
|
{"long_b1_at_3000", string(make([]byte, 3000)) + "a" + string(make([]byte, 1000)), 'a', 'b', 3000},
|
||||||
|
{"long_b2_at_3000", string(make([]byte, 3000)) + "b" + string(make([]byte, 1000)), 'a', 'b', 3000},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := IndexByteTwo([]byte(tt.s), tt.b1, tt.b2)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("IndexByteTwo(%q, %c, %c) = %d, want %d", tt.s[:min(len(tt.s), 40)], tt.b1, tt.b2, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exhaustive test: compare against loop reference for various lengths,
|
||||||
|
// including sizes around SIMD block boundaries (16, 32, 64).
|
||||||
|
for n := 0; n <= 256; n++ {
|
||||||
|
data := make([]byte, n)
|
||||||
|
for i := range data {
|
||||||
|
data[i] = byte('c' + (i % 20))
|
||||||
|
}
|
||||||
|
// Test with match at every position
|
||||||
|
for pos := 0; pos < n; pos++ {
|
||||||
|
for _, b := range []byte{'A', 'B'} {
|
||||||
|
data[pos] = b
|
||||||
|
got := IndexByteTwo(data, 'A', 'B')
|
||||||
|
want := loopIndexByteTwo(data, 'A', 'B')
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("IndexByteTwo(len=%d, match=%c@%d) = %d, want %d", n, b, pos, got, want)
|
||||||
|
}
|
||||||
|
data[pos] = byte('c' + (pos % 20))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Test with no match
|
||||||
|
got := IndexByteTwo(data, 'A', 'B')
|
||||||
|
if got != -1 {
|
||||||
|
t.Fatalf("IndexByteTwo(len=%d, no match) = %d, want -1", n, got)
|
||||||
|
}
|
||||||
|
// Test with both bytes present
|
||||||
|
if n >= 2 {
|
||||||
|
data[n/3] = 'A'
|
||||||
|
data[n*2/3] = 'B'
|
||||||
|
got := IndexByteTwo(data, 'A', 'B')
|
||||||
|
want := loopIndexByteTwo(data, 'A', 'B')
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("IndexByteTwo(len=%d, both@%d,%d) = %d, want %d", n, n/3, n*2/3, got, want)
|
||||||
|
}
|
||||||
|
data[n/3] = byte('c' + ((n / 3) % 20))
|
||||||
|
data[n*2/3] = byte('c' + ((n * 2 / 3) % 20))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLastIndexByteTwo(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
s string
|
||||||
|
b1 byte
|
||||||
|
b2 byte
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"empty", "", 'a', 'b', -1},
|
||||||
|
{"single_b1", "a", 'a', 'b', 0},
|
||||||
|
{"single_b2", "b", 'a', 'b', 0},
|
||||||
|
{"single_none", "c", 'a', 'b', -1},
|
||||||
|
{"b1_last", "xbxa", 'a', 'b', 3},
|
||||||
|
{"b2_last", "xaxb", 'a', 'b', 3},
|
||||||
|
{"same_byte", "axx", 'a', 'a', 0},
|
||||||
|
{"at_start", "axxxx", 'a', 'b', 0},
|
||||||
|
{"both_present", "axbx", 'a', 'b', 2},
|
||||||
|
{"not_found", "xxxxxxxx", 'a', 'b', -1},
|
||||||
|
{"long_b1_at_3000", string(make([]byte, 3000)) + "a" + string(make([]byte, 1000)), 'a', 'b', 3000},
|
||||||
|
{"long_b2_at_end", string(make([]byte, 4000)) + "b", 'a', 'b', 4000},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := lastIndexByteTwo([]byte(tt.s), tt.b1, tt.b2)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("lastIndexByteTwo(%q, %c, %c) = %d, want %d", tt.s[:min(len(tt.s), 40)], tt.b1, tt.b2, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exhaustive test against loop reference
|
||||||
|
for n := 0; n <= 256; n++ {
|
||||||
|
data := make([]byte, n)
|
||||||
|
for i := range data {
|
||||||
|
data[i] = byte('c' + (i % 20))
|
||||||
|
}
|
||||||
|
for pos := 0; pos < n; pos++ {
|
||||||
|
for _, b := range []byte{'A', 'B'} {
|
||||||
|
data[pos] = b
|
||||||
|
got := lastIndexByteTwo(data, 'A', 'B')
|
||||||
|
want := refLastIndexByteTwo(data, 'A', 'B')
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("lastIndexByteTwo(len=%d, match=%c@%d) = %d, want %d", n, b, pos, got, want)
|
||||||
|
}
|
||||||
|
data[pos] = byte('c' + (pos % 20))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No match
|
||||||
|
got := lastIndexByteTwo(data, 'A', 'B')
|
||||||
|
if got != -1 {
|
||||||
|
t.Fatalf("lastIndexByteTwo(len=%d, no match) = %d, want -1", n, got)
|
||||||
|
}
|
||||||
|
// Both bytes present
|
||||||
|
if n >= 2 {
|
||||||
|
data[n/3] = 'A'
|
||||||
|
data[n*2/3] = 'B'
|
||||||
|
got := lastIndexByteTwo(data, 'A', 'B')
|
||||||
|
want := refLastIndexByteTwo(data, 'A', 'B')
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("lastIndexByteTwo(len=%d, both@%d,%d) = %d, want %d", n, n/3, n*2/3, got, want)
|
||||||
|
}
|
||||||
|
data[n/3] = byte('c' + ((n / 3) % 20))
|
||||||
|
data[n*2/3] = byte('c' + ((n * 2 / 3) % 20))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func FuzzIndexByteTwo(f *testing.F) {
|
||||||
|
f.Add([]byte("hello world"), byte('o'), byte('l'))
|
||||||
|
f.Add([]byte(""), byte('a'), byte('b'))
|
||||||
|
f.Add([]byte("aaa"), byte('a'), byte('a'))
|
||||||
|
f.Fuzz(func(t *testing.T, data []byte, b1, b2 byte) {
|
||||||
|
got := IndexByteTwo(data, b1, b2)
|
||||||
|
want := loopIndexByteTwo(data, b1, b2)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("IndexByteTwo(len=%d, b1=%d, b2=%d) = %d, want %d", len(data), b1, b2, got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func FuzzLastIndexByteTwo(f *testing.F) {
|
||||||
|
f.Add([]byte("hello world"), byte('o'), byte('l'))
|
||||||
|
f.Add([]byte(""), byte('a'), byte('b'))
|
||||||
|
f.Add([]byte("aaa"), byte('a'), byte('a'))
|
||||||
|
f.Fuzz(func(t *testing.T, data []byte, b1, b2 byte) {
|
||||||
|
got := lastIndexByteTwo(data, b1, b2)
|
||||||
|
want := refLastIndexByteTwo(data, b1, b2)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("lastIndexByteTwo(len=%d, b1=%d, b2=%d) = %d, want %d", len(data), b1, b2, got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reference implementations for correctness checking
|
||||||
|
func refIndexByteTwo(s []byte, b1, b2 byte) int {
|
||||||
|
i1 := bytes.IndexByte(s, b1)
|
||||||
|
if i1 == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
scope := s
|
||||||
|
if i1 > 0 {
|
||||||
|
scope = s[:i1]
|
||||||
|
}
|
||||||
|
if i2 := bytes.IndexByte(scope, b2); i2 >= 0 {
|
||||||
|
return i2
|
||||||
|
}
|
||||||
|
return i1
|
||||||
|
}
|
||||||
|
|
||||||
|
func loopIndexByteTwo(s []byte, b1, b2 byte) int {
|
||||||
|
for i, b := range s {
|
||||||
|
if b == b1 || b == b2 {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func refLastIndexByteTwo(s []byte, b1, b2 byte) int {
|
||||||
|
for i := len(s) - 1; i >= 0; i-- {
|
||||||
|
if s[i] == b1 || s[i] == b2 {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func benchIndexByteTwo(b *testing.B, size int, pos int) {
|
||||||
|
data := make([]byte, size)
|
||||||
|
for i := range data {
|
||||||
|
data[i] = byte('a' + (i % 20))
|
||||||
|
}
|
||||||
|
data[pos] = 'Z'
|
||||||
|
|
||||||
|
type impl struct {
|
||||||
|
name string
|
||||||
|
fn func([]byte, byte, byte) int
|
||||||
|
}
|
||||||
|
impls := []impl{
|
||||||
|
{"asm", IndexByteTwo},
|
||||||
|
{"2xIndexByte", refIndexByteTwo},
|
||||||
|
{"loop", loopIndexByteTwo},
|
||||||
|
}
|
||||||
|
for _, im := range impls {
|
||||||
|
b.Run(im.name, func(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
im.fn(data, 'Z', 'z')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func benchLastIndexByteTwo(b *testing.B, size int, pos int) {
|
||||||
|
data := make([]byte, size)
|
||||||
|
for i := range data {
|
||||||
|
data[i] = byte('a' + (i % 20))
|
||||||
|
}
|
||||||
|
data[pos] = 'Z'
|
||||||
|
|
||||||
|
type impl struct {
|
||||||
|
name string
|
||||||
|
fn func([]byte, byte, byte) int
|
||||||
|
}
|
||||||
|
impls := []impl{
|
||||||
|
{"asm", lastIndexByteTwo},
|
||||||
|
{"loop", refLastIndexByteTwo},
|
||||||
|
}
|
||||||
|
for _, im := range impls {
|
||||||
|
b.Run(im.name, func(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
im.fn(data, 'Z', 'z')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkIndexByteTwo_10(b *testing.B) { benchIndexByteTwo(b, 10, 8) }
|
||||||
|
func BenchmarkIndexByteTwo_100(b *testing.B) { benchIndexByteTwo(b, 100, 80) }
|
||||||
|
func BenchmarkIndexByteTwo_1000(b *testing.B) { benchIndexByteTwo(b, 1000, 800) }
|
||||||
|
func BenchmarkLastIndexByteTwo_10(b *testing.B) { benchLastIndexByteTwo(b, 10, 2) }
|
||||||
|
func BenchmarkLastIndexByteTwo_100(b *testing.B) { benchLastIndexByteTwo(b, 100, 20) }
|
||||||
|
func BenchmarkLastIndexByteTwo_1000(b *testing.B) { benchLastIndexByteTwo(b, 1000, 200) }
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
//go:build !386 && !amd64 && !arm64
|
||||||
|
|
||||||
|
package algo
|
||||||
|
|
||||||
|
// The byte-view scanners in runeindex_x86.go reinterpret a []rune as
|
||||||
|
// little-endian 4-byte lanes, which is not valid everywhere. Elsewhere the
|
||||||
|
// reference scanners are the implementation.
|
||||||
|
|
||||||
|
func indexAsciiRune(runes []rune, caseSensitive bool, b byte, from int) int {
|
||||||
|
return indexAsciiRuneRef(runes, caseSensitive, b, from)
|
||||||
|
}
|
||||||
|
|
||||||
|
func lastIndexAsciiRune(runes []rune, caseSensitive bool, b byte, from int) int {
|
||||||
|
return lastIndexAsciiRuneRef(runes, caseSensitive, b, from)
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexRune(runes []rune, r rune, from int) int {
|
||||||
|
return indexRuneRef(runes, r, from)
|
||||||
|
}
|
||||||
|
|
||||||
|
func lastIndexRune(runes []rune, r rune, from int) int {
|
||||||
|
return lastIndexRuneRef(runes, r, from)
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package algo
|
||||||
|
|
||||||
|
// Reference scanners over a []rune, with no representation tricks.
|
||||||
|
//
|
||||||
|
// They have two roles. Where reinterpreting a []rune as little-endian bytes is
|
||||||
|
// not valid, they are the shipped implementation, via runeindex_others.go.
|
||||||
|
// Everywhere else, the tests feed the same inputs to these and to the byte-view
|
||||||
|
// scanners in runeindex_x86.go and require identical answers.
|
||||||
|
//
|
||||||
|
// They carry no build tag so that both roles hold on every platform. Otherwise
|
||||||
|
// the portable build would be code that nothing here ever runs.
|
||||||
|
|
||||||
|
func indexAsciiRuneRef(runes []rune, caseSensitive bool, b byte, from int) int {
|
||||||
|
lower, upper := rune(b), rune(-1)
|
||||||
|
if !caseSensitive && b >= 'a' && b <= 'z' {
|
||||||
|
upper = rune(b - 32)
|
||||||
|
}
|
||||||
|
for i := from; i < len(runes); i++ {
|
||||||
|
if runes[i] == lower || runes[i] == upper {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func lastIndexAsciiRuneRef(runes []rune, caseSensitive bool, b byte, from int) int {
|
||||||
|
lower, upper := rune(b), rune(-1)
|
||||||
|
if !caseSensitive && b >= 'a' && b <= 'z' {
|
||||||
|
upper = rune(b - 32)
|
||||||
|
}
|
||||||
|
for i := len(runes) - 1; i >= from; i-- {
|
||||||
|
if runes[i] == lower || runes[i] == upper {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexRuneRef(runes []rune, r rune, from int) int {
|
||||||
|
for i := from; i < len(runes); i++ {
|
||||||
|
if runes[i] == r {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func lastIndexRuneRef(runes []rune, r rune, from int) int {
|
||||||
|
for i := len(runes) - 1; i >= from; i-- {
|
||||||
|
if runes[i] == r {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
//go:build 386 || amd64 || arm64
|
||||||
|
|
||||||
|
package algo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
// On these architectures a []rune is a little-endian array of 4-byte lanes, so
|
||||||
|
// an ASCII rune is the byte itself followed by three zero bytes at a 4-byte
|
||||||
|
// aligned offset. That lets the SIMD byte scanners run over the rune array
|
||||||
|
// directly: find the low byte, then confirm alignment and the three zeroes.
|
||||||
|
// A byte equal to the needle can also appear as the low byte of a multi-byte
|
||||||
|
// rune (0x0165 has low byte 'e'), which those two checks reject.
|
||||||
|
|
||||||
|
func runeBytes(runes []rune) []byte {
|
||||||
|
return unsafe.Slice((*byte)(unsafe.Pointer(unsafe.SliceData(runes))), len(runes)*4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// indexAsciiRune returns the index of the first rune equal to b, or to its
|
||||||
|
// uppercase form when ignoring case, at or after rune index from.
|
||||||
|
func indexAsciiRune(runes []rune, caseSensitive bool, b byte, from int) int {
|
||||||
|
view := runeBytes(runes)
|
||||||
|
both := !caseSensitive && b >= 'a' && b <= 'z'
|
||||||
|
for off := from * 4; off < len(view); {
|
||||||
|
var idx int
|
||||||
|
if both {
|
||||||
|
idx = IndexByteTwo(view[off:], b, b-32)
|
||||||
|
} else {
|
||||||
|
idx = bytes.IndexByte(view[off:], b)
|
||||||
|
}
|
||||||
|
if idx < 0 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
pos := off + idx
|
||||||
|
if pos&3 == 0 && view[pos+1]|view[pos+2]|view[pos+3] == 0 {
|
||||||
|
return pos >> 2
|
||||||
|
}
|
||||||
|
off = pos + 1
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// runeNeedle picks which of the rune's four bytes to scan for, and returns its
|
||||||
|
// lane index and value. A zero byte is a useless needle because every ASCII
|
||||||
|
// rune contributes three of them, so U+AE00 scanned by its low byte would hit
|
||||||
|
// on almost every character of an ASCII-heavy line. Prefer a byte that cannot
|
||||||
|
// occur in an ASCII rune at all, then any non-zero byte.
|
||||||
|
func runeNeedle(r rune) (int, byte) {
|
||||||
|
var b [4]byte
|
||||||
|
b[0], b[1], b[2], b[3] = byte(r), byte(r>>8), byte(r>>16), byte(r>>24)
|
||||||
|
for i, v := range b {
|
||||||
|
if v >= 0x80 {
|
||||||
|
return i, v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, v := range b {
|
||||||
|
if v != 0 {
|
||||||
|
return i, v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func runeAt(view []byte, start int) rune {
|
||||||
|
return rune(view[start]) | rune(view[start+1])<<8 | rune(view[start+2])<<16 | rune(view[start+3])<<24
|
||||||
|
}
|
||||||
|
|
||||||
|
// indexRune returns the index of the first rune equal to r at or after rune
|
||||||
|
// index from. Case is not folded, so the caller must have established that no
|
||||||
|
// other rune can transform into r.
|
||||||
|
func indexRune(runes []rune, r rune, from int) int {
|
||||||
|
view := runeBytes(runes)
|
||||||
|
lane, needle := runeNeedle(r)
|
||||||
|
for off := from*4 + lane; off < len(view); {
|
||||||
|
idx := bytes.IndexByte(view[off:], needle)
|
||||||
|
if idx < 0 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
pos := off + idx
|
||||||
|
if start := pos - lane; start&3 == 0 && runeAt(view, start) == r {
|
||||||
|
return start >> 2
|
||||||
|
}
|
||||||
|
off = pos + 1
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastIndexRune is indexRune scanning backwards from the end.
|
||||||
|
func lastIndexRune(runes []rune, r rune, from int) int {
|
||||||
|
view := runeBytes(runes)
|
||||||
|
lane, needle := runeNeedle(r)
|
||||||
|
for end := len(view); end > from*4+lane; {
|
||||||
|
idx := bytes.LastIndexByte(view[from*4+lane:end], needle)
|
||||||
|
if idx < 0 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
pos := from*4 + lane + idx
|
||||||
|
if start := pos - lane; start&3 == 0 && runeAt(view, start) == r {
|
||||||
|
return start >> 2
|
||||||
|
}
|
||||||
|
end = pos
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastIndexAsciiRune is indexAsciiRune scanning backwards from the end.
|
||||||
|
func lastIndexAsciiRune(runes []rune, caseSensitive bool, b byte, from int) int {
|
||||||
|
view := runeBytes(runes)[from*4:]
|
||||||
|
both := !caseSensitive && b >= 'a' && b <= 'z'
|
||||||
|
for end := len(view); end > 0; {
|
||||||
|
var idx int
|
||||||
|
if both {
|
||||||
|
idx = lastIndexByteTwo(view[:end], b, b-32)
|
||||||
|
} else {
|
||||||
|
idx = bytes.LastIndexByte(view[:end], b)
|
||||||
|
}
|
||||||
|
if idx < 0 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if idx&3 == 0 && view[idx+1]|view[idx+2]|view[idx+3] == 0 {
|
||||||
|
return from + idx>>2
|
||||||
|
}
|
||||||
|
end = idx
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
@@ -0,0 +1,479 @@
|
|||||||
|
package algo
|
||||||
|
|
||||||
|
// Correctness tests for the rune-array prefilter (Step C).
|
||||||
|
//
|
||||||
|
// The prefilter may narrow the search scope but must never change a Result or
|
||||||
|
// its positions, and must never reject an item the general path would match.
|
||||||
|
// Each result is compared against the same code with the prefilter disabled.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/junegunn/fzf/src/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
// foldForTest mirrors what Phase 2 does to a non-ASCII text rune: lowercase if
|
||||||
|
// uppercase, then normalize.
|
||||||
|
func foldForTest(r rune, normalize bool) rune {
|
||||||
|
if charClassOfNonAscii(r) == charUpper {
|
||||||
|
r = unicode.To(unicode.LowerCase, r)
|
||||||
|
}
|
||||||
|
if normalize {
|
||||||
|
r = normalizeRune(r)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// The prefilter is only safe on items whose runes cannot become ASCII. This
|
||||||
|
// verifies util.MayFoldToAscii as a superset of the runes that actually can,
|
||||||
|
// over the whole Unicode range and both normalization modes. If normalize.go
|
||||||
|
// or the Go unicode tables change, this fails.
|
||||||
|
func TestMayFoldToAsciiIsSuperset(t *testing.T) {
|
||||||
|
missed := 0
|
||||||
|
for r := rune(utf8.RuneSelf); r <= unicode.MaxRune; r++ {
|
||||||
|
if r >= 0xD800 && r <= 0xDFFF {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, normalize := range []bool{true, false} {
|
||||||
|
if foldForTest(r, normalize) < utf8.RuneSelf && !util.MayFoldToAscii(r) {
|
||||||
|
if missed++; missed < 10 {
|
||||||
|
t.Errorf("U+%04X folds to ASCII (normalize=%v) but MayFoldToAscii is false", r, normalize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if missed > 0 {
|
||||||
|
t.Fatalf("%d runes fold to ASCII without being flagged", missed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scripts that must stay unflagged, otherwise the prefilter never runs for
|
||||||
|
// them and Step C has no effect.
|
||||||
|
func TestMayFoldToAsciiExcludesMajorScripts(t *testing.T) {
|
||||||
|
for _, s := range []struct {
|
||||||
|
name string
|
||||||
|
lo, hi rune
|
||||||
|
}{
|
||||||
|
{"Cyrillic", 0x0400, 0x04FF}, {"Greek", 0x0370, 0x03FF}, {"Hebrew", 0x0590, 0x05FF},
|
||||||
|
{"Arabic", 0x0600, 0x06FF}, {"Thai", 0x0E00, 0x0E7F}, {"Devanagari", 0x0900, 0x097F},
|
||||||
|
{"CJK", 0x4E00, 0x9FFF}, {"Hangul", 0xAC00, 0xD7A3}, {"kana", 0x3040, 0x30FF},
|
||||||
|
{"box drawing", 0x2500, 0x257F}, {"emoji", 0x1F300, 0x1FAFF},
|
||||||
|
// These sit between the Latin blocks and were included in an earlier,
|
||||||
|
// wider grouping of foldableRanges. General Punctuation matters most:
|
||||||
|
// curly quotes, en and em dashes and the ellipsis are in it.
|
||||||
|
{"Greek Extended", 0x1F00, 0x1FFF}, {"General Punctuation", 0x2000, 0x206F},
|
||||||
|
{"Currency Symbols", 0x20A0, 0x20CF}, {"CJK Symbols", 0x3000, 0x303F},
|
||||||
|
} {
|
||||||
|
for r := s.lo; r <= s.hi; r++ {
|
||||||
|
if util.MayFoldToAscii(r) {
|
||||||
|
t.Errorf("%s U+%04X should not be flagged foldable", s.name, r)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The byte-view scan must agree with the shipped reference scanners. The interesting inputs
|
||||||
|
// are runes whose low byte collides with the needle (U+0165 has low byte 'e')
|
||||||
|
// and runes sharing a lane offset, which the alignment and zero checks reject.
|
||||||
|
func TestIndexAsciiRuneMatchesReference(t *testing.T) {
|
||||||
|
rng := rand.New(rand.NewSource(3))
|
||||||
|
alphabet := []rune{'a', 'A', 'e', 'E', '/', '1', 0x0165, 0x00E9, 0x4E00, 0xD55C,
|
||||||
|
0x1F389, 0x0065 + 0x100, 0x0041 + 0x100, 0x2F65}
|
||||||
|
for trial := range 20000 {
|
||||||
|
n := rng.Intn(24)
|
||||||
|
runes := make([]rune, n)
|
||||||
|
for i := range runes {
|
||||||
|
runes[i] = alphabet[rng.Intn(len(alphabet))]
|
||||||
|
}
|
||||||
|
b := []byte{'a', 'e', 'A', 'E', '/', '1'}[rng.Intn(6)]
|
||||||
|
cs := rng.Intn(2) == 0
|
||||||
|
from := 0
|
||||||
|
if n > 0 {
|
||||||
|
from = rng.Intn(n)
|
||||||
|
}
|
||||||
|
if got, exp := indexAsciiRune(runes, cs, b, from), indexAsciiRuneRef(runes, cs, b, from); got != exp {
|
||||||
|
t.Fatalf("trial %d: indexAsciiRune(%U, cs=%v, %q, %d) = %d, expected %d", trial, runes, cs, b, from, got, exp)
|
||||||
|
}
|
||||||
|
if got, exp := lastIndexAsciiRune(runes, cs, b, from), lastIndexAsciiRuneRef(runes, cs, b, from); got != exp {
|
||||||
|
t.Fatalf("trial %d: lastIndexAsciiRune(%U, cs=%v, %q, %d) = %d, expected %d", trial, runes, cs, b, from, got, exp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Differential test: the prefilter must not change any Result or position.
|
||||||
|
// Corpora deliberately mix scripts that clear the foldable bit (CJK, Hangul,
|
||||||
|
// Cyrillic, emoji) with scripts that set it (accented Latin, fullwidth), so
|
||||||
|
// both the engaged and the bypassed path are exercised.
|
||||||
|
func TestRunePrefilterEquivalence(t *testing.T) {
|
||||||
|
t.Cleanup(func() { disableRunePrefilter = false })
|
||||||
|
rng := rand.New(rand.NewSource(4))
|
||||||
|
parts := []string{
|
||||||
|
"src", "util", "conf", "a", "e", "E", "A", "/", "_", "1", " ",
|
||||||
|
"漢字", "한글", "мир", "ελλ", "🎉", "café", "Müller", "naïve", "full",
|
||||||
|
"Å", "İ", "K", "ǰ", "ff",
|
||||||
|
}
|
||||||
|
patterns := []string{"a", "e", "conf", "src/util", "ae", "A", "E", "K", "k", "i", "//", "zz", "s l",
|
||||||
|
// non-ASCII patterns, the Step G path
|
||||||
|
"漢", "漢字", "한", "한글", "мир", "м", "ελλ", "🎉", "é", "ß", "Å", "İ", "f",
|
||||||
|
"漢a", "a漢", "한글/src", "🎉e"}
|
||||||
|
|
||||||
|
slab := util.MakeSlab(100*1024, 2048)
|
||||||
|
engaged, bypassed := 0, 0
|
||||||
|
|
||||||
|
for trial := range 30000 {
|
||||||
|
var sb strings.Builder
|
||||||
|
for range 1 + rng.Intn(8) {
|
||||||
|
sb.WriteString(parts[rng.Intn(len(parts))])
|
||||||
|
}
|
||||||
|
chars := util.ToChars([]byte(sb.String()))
|
||||||
|
if chars.IsBytes() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if chars.MayFoldToAscii() {
|
||||||
|
bypassed++
|
||||||
|
} else {
|
||||||
|
engaged++
|
||||||
|
}
|
||||||
|
pat := patterns[rng.Intn(len(patterns))]
|
||||||
|
cs := rng.Intn(2) == 0
|
||||||
|
if !cs {
|
||||||
|
pat = strings.ToLower(pat)
|
||||||
|
}
|
||||||
|
pattern := []rune(pat)
|
||||||
|
norm := rng.Intn(2) == 0
|
||||||
|
fwd := rng.Intn(2) == 0
|
||||||
|
wp := rng.Intn(2) == 0
|
||||||
|
|
||||||
|
disableRunePrefilter = true
|
||||||
|
expR, expP := FuzzyMatchV2(cs, norm, fwd, &chars, pattern, wp, slab)
|
||||||
|
disableRunePrefilter = false
|
||||||
|
gotR, gotP := FuzzyMatchV2(cs, norm, fwd, &chars, pattern, wp, slab)
|
||||||
|
|
||||||
|
if gotR != expR || !samePos(gotP, expP) {
|
||||||
|
t.Fatalf("trial %d: %q pattern=%q cs=%v norm=%v fwd=%v wp=%v\n prefilter on: %v %v\n prefilter off: %v %v",
|
||||||
|
trial, sb.String(), pat, cs, norm, fwd, wp, gotR, gotP, expR, expP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
disableRunePrefilter = false
|
||||||
|
t.Logf("prefilter engaged on %d items, bypassed on %d", engaged, bypassed)
|
||||||
|
if engaged == 0 || bypassed == 0 {
|
||||||
|
t.Fatalf("corpus did not exercise both paths (engaged=%d bypassed=%d)", engaged, bypassed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equivalence alone would still hold if the prefilter never filtered
|
||||||
|
// anything, so confirm it both rejects and narrows.
|
||||||
|
rejected, narrowed := 0, 0
|
||||||
|
for range 5000 {
|
||||||
|
var sb strings.Builder
|
||||||
|
for range 1 + rng.Intn(8) {
|
||||||
|
sb.WriteString(parts[rng.Intn(len(parts))])
|
||||||
|
}
|
||||||
|
chars := util.ToChars([]byte(sb.String()))
|
||||||
|
if chars.IsBytes() || chars.MayFoldToAscii() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pattern := []rune(patterns[rng.Intn(len(patterns))])
|
||||||
|
lo, hi := asciiFuzzyIndex(&chars, pattern, false)
|
||||||
|
switch {
|
||||||
|
case lo < 0:
|
||||||
|
rejected++
|
||||||
|
case hi-lo < chars.Length():
|
||||||
|
narrowed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Logf("prefilter rejected %d items, narrowed scope on %d", rejected, narrowed)
|
||||||
|
if rejected == 0 {
|
||||||
|
t.Fatal("prefilter never rejected an item, so equivalence proves nothing")
|
||||||
|
}
|
||||||
|
if narrowed == 0 {
|
||||||
|
t.Fatal("prefilter never narrowed the scope")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FuzzyMatchV1 shares asciiFuzzyIndex, so it needs the same guarantee.
|
||||||
|
func TestRunePrefilterEquivalenceV1(t *testing.T) {
|
||||||
|
t.Cleanup(func() { disableRunePrefilter = false })
|
||||||
|
rng := rand.New(rand.NewSource(5))
|
||||||
|
parts := []string{"src", "conf", "a", "e", "/", "漢字", "한글", "мир", "café", "Å", "🎉"}
|
||||||
|
slab := util.MakeSlab(100*1024, 2048)
|
||||||
|
for trial := range 20000 {
|
||||||
|
var sb strings.Builder
|
||||||
|
for range 1 + rng.Intn(6) {
|
||||||
|
sb.WriteString(parts[rng.Intn(len(parts))])
|
||||||
|
}
|
||||||
|
chars := util.ToChars([]byte(sb.String()))
|
||||||
|
if chars.IsBytes() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pattern := []rune([]string{"a", "e", "conf", "src", "ae", "zz"}[rng.Intn(6)])
|
||||||
|
cs, norm, fwd, wp := rng.Intn(2) == 0, rng.Intn(2) == 0, rng.Intn(2) == 0, rng.Intn(2) == 0
|
||||||
|
|
||||||
|
disableRunePrefilter = true
|
||||||
|
expR, expP := FuzzyMatchV1(cs, norm, fwd, &chars, pattern, wp, slab)
|
||||||
|
disableRunePrefilter = false
|
||||||
|
gotR, gotP := FuzzyMatchV1(cs, norm, fwd, &chars, pattern, wp, slab)
|
||||||
|
|
||||||
|
if gotR != expR || !samePos(gotP, expP) {
|
||||||
|
t.Fatalf("trial %d: %q pattern=%q\n prefilter on: %v %v\n prefilter off: %v %v",
|
||||||
|
trial, sb.String(), string(pattern), gotR, gotP, expR, expP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
disableRunePrefilter = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeRune skips the map when util.MayFoldToAscii rejects the rune. That
|
||||||
|
// is only sound if every key of the map is flagged, since a flagged-false rune
|
||||||
|
// is returned unchanged.
|
||||||
|
func TestNormalizedKeysAreFlagged(t *testing.T) {
|
||||||
|
for k := range normalized {
|
||||||
|
if !util.MayFoldToAscii(k) {
|
||||||
|
t.Errorf("normalized key U+%04X (%c) is not flagged by MayFoldToAscii", k, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guarding normalizeRune must not change what it returns, for any rune.
|
||||||
|
func TestNormalizeRuneUnchangedByGuard(t *testing.T) {
|
||||||
|
for r := rune(0); r <= unicode.MaxRune; r++ {
|
||||||
|
if r >= 0xD800 && r <= 0xDFFF {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
exp := r
|
||||||
|
if r >= 0x00C0 && r <= 0xFF61 {
|
||||||
|
if n := normalized[r]; n > 0 {
|
||||||
|
exp = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := normalizeRune(r); got != exp {
|
||||||
|
t.Fatalf("normalizeRune(U+%04X) = U+%04X, expected U+%04X", r, got, exp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step G lets non-ASCII pattern runes use the scan, but only when no other
|
||||||
|
// rune can transform into them. Being uncased is not sufficient: U+00DF has no
|
||||||
|
// simple uppercase yet U+1E9E lowercases onto it. This checks the guard against
|
||||||
|
// the full preimage relation over all of Unicode.
|
||||||
|
func TestRunePrefilterableGuardIsSound(t *testing.T) {
|
||||||
|
preimage := map[rune][]rune{}
|
||||||
|
for r := rune(0); r <= unicode.MaxRune; r++ {
|
||||||
|
if r >= 0xD800 && r <= 0xDFFF {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if charClassOfNonAscii(r) == charUpper {
|
||||||
|
if l := unicode.To(unicode.LowerCase, r); l != r {
|
||||||
|
preimage[l] = append(preimage[l], r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clean := util.ToChars([]byte("漢字")) // rune mode, fold bit clear
|
||||||
|
admitted, violations := 0, 0
|
||||||
|
for r := rune(utf8.RuneSelf); r <= unicode.MaxRune; r++ {
|
||||||
|
if r >= 0xD800 && r <= 0xDFFF {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !runePrefilterable(&clean, []rune{r}, false) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
admitted++
|
||||||
|
if extra := preimage[r]; len(extra) > 0 {
|
||||||
|
violations++
|
||||||
|
if violations <= 5 {
|
||||||
|
t.Errorf("guard admits U+%04X but %U lowercases onto it", r, extra)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Logf("guard admits %d non-ASCII pattern runes, unsound for %d", admitted, violations)
|
||||||
|
|
||||||
|
// The scripts this step exists for must be fully admitted.
|
||||||
|
for _, s := range []struct {
|
||||||
|
name string
|
||||||
|
lo, hi rune
|
||||||
|
}{{"CJK", 0x4E00, 0x9FFF}, {"Hangul", 0xAC00, 0xD7A3}, {"kana", 0x3040, 0x30FF},
|
||||||
|
{"Thai", 0x0E00, 0x0E7F}, {"emoji", 0x1F300, 0x1FAFF}} {
|
||||||
|
for r := s.lo; r <= s.hi; r++ {
|
||||||
|
if !runePrefilterable(&clean, []rune{r}, false) {
|
||||||
|
t.Errorf("%s U+%04X should be admitted", s.name, r)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Step G path must actually run and reject, otherwise the equivalence
|
||||||
|
// test above proves nothing about non-ASCII patterns.
|
||||||
|
func TestNonAsciiPatternPrefilterEngages(t *testing.T) {
|
||||||
|
rng := rand.New(rand.NewSource(6))
|
||||||
|
parts := []string{"漢字", "한글", "src", "conf", "/", "мир", "🎉"}
|
||||||
|
engaged, rejected, narrowed, bypassed := 0, 0, 0, 0
|
||||||
|
for range 5000 {
|
||||||
|
var sb strings.Builder
|
||||||
|
for range 1 + rng.Intn(6) {
|
||||||
|
sb.WriteString(parts[rng.Intn(len(parts))])
|
||||||
|
}
|
||||||
|
chars := util.ToChars([]byte(sb.String()))
|
||||||
|
if chars.IsBytes() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pattern := []rune([]string{"漢", "漢字", "한글", "мир", "🎉", "é", "ß"}[rng.Intn(7)])
|
||||||
|
if !runePrefilterable(&chars, pattern, false) {
|
||||||
|
bypassed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
engaged++
|
||||||
|
lo, hi := asciiFuzzyIndex(&chars, pattern, false)
|
||||||
|
switch {
|
||||||
|
case lo < 0:
|
||||||
|
rejected++
|
||||||
|
case hi-lo < chars.Length():
|
||||||
|
narrowed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Logf("non-ASCII patterns: engaged %d, bypassed %d, rejected %d, narrowed %d",
|
||||||
|
engaged, bypassed, rejected, narrowed)
|
||||||
|
if engaged == 0 || rejected == 0 {
|
||||||
|
t.Fatalf("non-ASCII pattern path not exercised (engaged=%d rejected=%d)", engaged, rejected)
|
||||||
|
}
|
||||||
|
if bypassed == 0 {
|
||||||
|
t.Fatal("cased and foldable patterns should still bypass")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// indexRune picks which byte lane to scan, and is checked against the shipped
|
||||||
|
// reference scanners rather than a copy of them. Zero-low-byte runes (U+AE00) and
|
||||||
|
// runes whose lanes collide with common ASCII bytes are the cases that break a
|
||||||
|
// naive low-byte scan, so the alphabet includes both.
|
||||||
|
func TestIndexRuneMatchesReference(t *testing.T) {
|
||||||
|
|
||||||
|
alphabet := []rune{
|
||||||
|
'a', 'e', 'N', '/', 0x00,
|
||||||
|
0xAE00, 0xAC00, 0xD55C, // Hangul, low byte zero for U+AE00
|
||||||
|
0x4E00, 0x6587, 0x9FFF, // CJK, U+4E00 has zero low byte
|
||||||
|
0x3040, 0x0E00, // kana, Thai with zero low byte
|
||||||
|
0x1F389, 0x1F300, // emoji, 3 significant bytes
|
||||||
|
0x0100, 0x0165, 0x00E9, // low byte zero / ASCII-colliding lanes
|
||||||
|
}
|
||||||
|
rng := rand.New(rand.NewSource(7))
|
||||||
|
for trial := range 30000 {
|
||||||
|
n := rng.Intn(20)
|
||||||
|
runes := make([]rune, n)
|
||||||
|
for i := range runes {
|
||||||
|
runes[i] = alphabet[rng.Intn(len(alphabet))]
|
||||||
|
}
|
||||||
|
r := alphabet[rng.Intn(len(alphabet))]
|
||||||
|
from := 0
|
||||||
|
if n > 0 {
|
||||||
|
from = rng.Intn(n)
|
||||||
|
}
|
||||||
|
if got, exp := indexRune(runes, r, from), indexRuneRef(runes, r, from); got != exp {
|
||||||
|
t.Fatalf("trial %d: indexRune(%U, U+%04X, %d) = %d, expected %d", trial, runes, r, from, got, exp)
|
||||||
|
}
|
||||||
|
if got, exp := lastIndexRune(runes, r, from), lastIndexRuneRef(runes, r, from); got != exp {
|
||||||
|
t.Fatalf("trial %d: lastIndexRune(%U, U+%04X, %d) = %d, expected %d", trial, runes, r, from, got, exp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// preparePattern mirrors what pattern.go guarantees the algo functions:
|
||||||
|
// lowercased when case-insensitive, normalized when normalize is on.
|
||||||
|
func preparePattern(pat string, caseSensitive, normalize bool) []rune {
|
||||||
|
if !caseSensitive {
|
||||||
|
pat = strings.ToLower(pat)
|
||||||
|
}
|
||||||
|
r := []rune(pat)
|
||||||
|
if normalize {
|
||||||
|
r = NormalizeRunes(r)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// FuzzRunePrefilter drives arbitrary rune-mode input and arbitrary patterns
|
||||||
|
// through the prefilter and through the same code with it disabled, and
|
||||||
|
// requires identical Results and positions. The existing fast-path fuzzers
|
||||||
|
// only generate byte-mode input, so they never reach this path.
|
||||||
|
func FuzzRunePrefilter(f *testing.F) {
|
||||||
|
for _, in := range []string{
|
||||||
|
"한글/src/util.go", "漢字/conf", "café/binutils", "мир/test", "🎉/a",
|
||||||
|
"ABC.txt", "Ångström", "ǰ/ß/İ", "a漢b한c", "Āā",
|
||||||
|
} {
|
||||||
|
for _, p := range []string{"a", "conf", "漢", "한글", "мир", "ß", "É", "a漢"} {
|
||||||
|
f.Add(in, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slab := util.MakeSlab(200*1024, 4096)
|
||||||
|
f.Fuzz(func(t *testing.T, input, pat string) {
|
||||||
|
if len(input) > 512 || len(pat) == 0 || len(pat) > 32 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chars := util.ToChars([]byte(input))
|
||||||
|
if chars.IsBytes() {
|
||||||
|
return // byte mode is covered by the existing fuzzers
|
||||||
|
}
|
||||||
|
for _, cs := range []bool{false, true} {
|
||||||
|
for _, norm := range []bool{false, true} {
|
||||||
|
p := preparePattern(pat, cs, norm)
|
||||||
|
if len(p) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, fwd := range []bool{true, false} {
|
||||||
|
for _, wp := range []bool{false, true} {
|
||||||
|
for _, fn := range []Algo{FuzzyMatchV2, FuzzyMatchV1, ExactMatchNaive} {
|
||||||
|
disableRunePrefilter = true
|
||||||
|
expR, expP := fn(cs, norm, fwd, &chars, p, wp, slab)
|
||||||
|
disableRunePrefilter = false
|
||||||
|
gotR, gotP := fn(cs, norm, fwd, &chars, p, wp, slab)
|
||||||
|
if gotR != expR || !samePos(gotP, expP) {
|
||||||
|
t.Fatalf("input=%q pattern=%q cs=%v norm=%v fwd=%v wp=%v\n prefilter on: %v %v\n prefilter off: %v %v",
|
||||||
|
input, pat, cs, norm, fwd, wp, gotR, gotP, expR, expP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunesToChars can produce rune-mode Chars holding zero runes, which sends a
|
||||||
|
// nil pointer through unsafe.SliceData in runeBytes. ToChars cannot produce
|
||||||
|
// this (an empty input is byte mode), so it needs its own test.
|
||||||
|
func TestEmptyRuneModeChars(t *testing.T) {
|
||||||
|
t.Cleanup(func() { disableRunePrefilter = false })
|
||||||
|
slab := util.MakeSlab(100*1024, 2048)
|
||||||
|
for _, runes := range [][]rune{{}, nil, {'a'}, {0x4E00}} {
|
||||||
|
chars := util.RunesToChars(runes)
|
||||||
|
if chars.IsBytes() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, pat := range []string{"a", "漢", "ab"} {
|
||||||
|
p := []rune(pat)
|
||||||
|
for _, fn := range []Algo{FuzzyMatchV2, FuzzyMatchV1, ExactMatchNaive,
|
||||||
|
PrefixMatch, SuffixMatch, EqualMatch} {
|
||||||
|
disableRunePrefilter = true
|
||||||
|
expR, expP := fn(false, true, true, &chars, p, true, slab)
|
||||||
|
disableRunePrefilter = false
|
||||||
|
gotR, gotP := fn(false, true, true, &chars, p, true, slab)
|
||||||
|
if gotR != expR || !samePos(gotP, expP) {
|
||||||
|
t.Errorf("runes=%U pat=%q: prefilter on %v %v, off %v %v", runes, pat, gotR, gotP, expR, expP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MayFoldToAscii subtracts before bounds-checking, so a rune below the range
|
||||||
|
// (including a negative one, which utf8 decoding never produces but callers
|
||||||
|
// could construct) must not wrap into a false positive.
|
||||||
|
func TestMayFoldToAsciiOutOfRange(t *testing.T) {
|
||||||
|
for _, r := range []rune{-1, -0x10000, 0, 'a', 0x7F, 0xBF, 0xFF62, unicode.MaxRune, unicode.MaxRune + 1} {
|
||||||
|
if util.MayFoldToAscii(r) {
|
||||||
|
t.Errorf("MayFoldToAscii(%d) = true, expected false", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-11
@@ -6,6 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/junegunn/fzf/src/algo"
|
||||||
"github.com/junegunn/fzf/src/tui"
|
"github.com/junegunn/fzf/src/tui"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -123,31 +124,31 @@ func toAnsiString(color tui.Color, offset int) string {
|
|||||||
return ret + ";"
|
return ret + ";"
|
||||||
}
|
}
|
||||||
|
|
||||||
func isPrint(c uint8) bool {
|
|
||||||
return '\x20' <= c && c <= '\x7e'
|
|
||||||
}
|
|
||||||
|
|
||||||
func matchOperatingSystemCommand(s string, start int) int {
|
func matchOperatingSystemCommand(s string, start int) int {
|
||||||
// `\x1b][0-9][;:][[:print:]]+(?:\x1b\\\\|\x07)`
|
// `\x1b][0-9][;:][[:print:]]+(?:\x1b\\\\|\x07)`
|
||||||
// ^ match starting here after the first printable character
|
// ^ match starting here after the first printable character
|
||||||
//
|
//
|
||||||
i := start // prefix matched in nextAnsiEscapeSequence()
|
i := start // prefix matched in nextAnsiEscapeSequence()
|
||||||
for ; i < len(s) && isPrint(s[i]); i++ {
|
|
||||||
|
// Find the terminator: BEL (\x07) or ESC (\x1b) for ST (\x1b\\)
|
||||||
|
idx := algo.IndexByteTwo(stringBytes(s[i:]), '\x07', '\x1b')
|
||||||
|
if idx < 0 {
|
||||||
|
return -1
|
||||||
}
|
}
|
||||||
if i < len(s) {
|
i += idx
|
||||||
|
|
||||||
if s[i] == '\x07' {
|
if s[i] == '\x07' {
|
||||||
return i + 1
|
return i + 1
|
||||||
}
|
}
|
||||||
// `\x1b]8;PARAMS;URI\x1b\\TITLE\x1b]8;;\x1b`
|
// `\x1b]8;PARAMS;URI\x1b\\TITLE\x1b]8;;\x1b`
|
||||||
// ------
|
// ------
|
||||||
if s[i] == '\x1b' && i < len(s)-1 && s[i+1] == '\\' {
|
if i < len(s)-1 && s[i+1] == '\\' {
|
||||||
return i + 2
|
return i + 2
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// `\x1b]8;PARAMS;URI\x1b\\TITLE\x1b]8;;\x1b`
|
// `\x1b]8;PARAMS;URI\x1b\\TITLE\x1b]8;;\x1b`
|
||||||
// ------------
|
// ------------
|
||||||
if i < len(s) && s[:i+1] == "\x1b]8;;\x1b" {
|
if s[:i+1] == "\x1b]8;;\x1b" {
|
||||||
return i + 1
|
return i + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,7 +234,7 @@ Loop:
|
|||||||
|
|
||||||
// \x1b][0-9]+[;:][[:print:]]+(?:\x1b\\\\|\x07)
|
// \x1b][0-9]+[;:][[:print:]]+(?:\x1b\\\\|\x07)
|
||||||
// ---------------
|
// ---------------
|
||||||
if j > 2 && i+j+1 < len(s) && (s[i+j] == ';' || s[i+j] == ':') && isPrint(s[i+j+1]) {
|
if j > 2 && i+j+1 < len(s) && (s[i+j] == ';' || s[i+j] == ':') && s[i+j+1] >= '\x20' {
|
||||||
if k := matchOperatingSystemCommand(s[i:], j+2); k != -1 {
|
if k := matchOperatingSystemCommand(s[i:], j+2); k != -1 {
|
||||||
return i, i + k
|
return i, i + k
|
||||||
}
|
}
|
||||||
@@ -483,7 +484,7 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
|
|||||||
state.attr = state.attr | tui.Italic
|
state.attr = state.attr | tui.Italic
|
||||||
case 4:
|
case 4:
|
||||||
if sep == ':' {
|
if sep == ':' {
|
||||||
// SGR 4:N — underline style sub-parameter
|
// SGR 4:N - underline style sub-parameter
|
||||||
var subNum int
|
var subNum int
|
||||||
subNum, _, ansiCode = parseAnsiCode(ansiCode)
|
subNum, _, ansiCode = parseAnsiCode(ansiCode)
|
||||||
state.attr = state.attr &^ tui.UnderlineStyleMask
|
state.attr = state.attr &^ tui.UnderlineStyleMask
|
||||||
|
|||||||
+18
-15
@@ -2,10 +2,13 @@ package fzf
|
|||||||
|
|
||||||
import "sync"
|
import "sync"
|
||||||
|
|
||||||
// queryCache associates strings to lists of items
|
// ChunkBitmap is a bitmap with one bit per item in a chunk.
|
||||||
type queryCache map[string][]Result
|
type ChunkBitmap [chunkBitWords]uint64
|
||||||
|
|
||||||
// ChunkCache associates Chunk and query string to lists of items
|
// queryCache associates query strings to bitmaps of matching items
|
||||||
|
type queryCache map[string]ChunkBitmap
|
||||||
|
|
||||||
|
// ChunkCache associates Chunk and query string to bitmaps
|
||||||
type ChunkCache struct {
|
type ChunkCache struct {
|
||||||
mutex sync.Mutex
|
mutex sync.Mutex
|
||||||
cache map[*Chunk]*queryCache
|
cache map[*Chunk]*queryCache
|
||||||
@@ -30,9 +33,9 @@ func (cc *ChunkCache) retire(chunk ...*Chunk) {
|
|||||||
cc.mutex.Unlock()
|
cc.mutex.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add adds the list to the cache
|
// Add stores the bitmap for the given chunk and key
|
||||||
func (cc *ChunkCache) Add(chunk *Chunk, key string, list []Result) {
|
func (cc *ChunkCache) Add(chunk *Chunk, key string, bitmap ChunkBitmap, matchCount int) {
|
||||||
if len(key) == 0 || !chunk.IsFull() || len(list) > queryCacheMax {
|
if len(key) == 0 || !chunk.IsFull() || matchCount > queryCacheMax {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,11 +47,11 @@ func (cc *ChunkCache) Add(chunk *Chunk, key string, list []Result) {
|
|||||||
cc.cache[chunk] = &queryCache{}
|
cc.cache[chunk] = &queryCache{}
|
||||||
qc = cc.cache[chunk]
|
qc = cc.cache[chunk]
|
||||||
}
|
}
|
||||||
(*qc)[key] = list
|
(*qc)[key] = bitmap
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lookup is called to lookup ChunkCache
|
// Lookup returns the bitmap for the exact key
|
||||||
func (cc *ChunkCache) Lookup(chunk *Chunk, key string) []Result {
|
func (cc *ChunkCache) Lookup(chunk *Chunk, key string) *ChunkBitmap {
|
||||||
if len(key) == 0 || !chunk.IsFull() {
|
if len(key) == 0 || !chunk.IsFull() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -58,15 +61,15 @@ func (cc *ChunkCache) Lookup(chunk *Chunk, key string) []Result {
|
|||||||
|
|
||||||
qc, ok := cc.cache[chunk]
|
qc, ok := cc.cache[chunk]
|
||||||
if ok {
|
if ok {
|
||||||
list, ok := (*qc)[key]
|
if bm, ok := (*qc)[key]; ok {
|
||||||
if ok {
|
return &bm
|
||||||
return list
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cc *ChunkCache) Search(chunk *Chunk, key string) []Result {
|
// Search finds the bitmap for the longest prefix or suffix of the key
|
||||||
|
func (cc *ChunkCache) Search(chunk *Chunk, key string) *ChunkBitmap {
|
||||||
if len(key) == 0 || !chunk.IsFull() {
|
if len(key) == 0 || !chunk.IsFull() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -86,8 +89,8 @@ func (cc *ChunkCache) Search(chunk *Chunk, key string) []Result {
|
|||||||
prefix := key[:len(key)-idx]
|
prefix := key[:len(key)-idx]
|
||||||
suffix := key[idx:]
|
suffix := key[idx:]
|
||||||
for _, substr := range [2]string{prefix, suffix} {
|
for _, substr := range [2]string{prefix, suffix} {
|
||||||
if cached, found := (*qc)[substr]; found {
|
if bm, found := (*qc)[substr]; found {
|
||||||
return cached
|
return &bm
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-11
@@ -6,34 +6,34 @@ func TestChunkCache(t *testing.T) {
|
|||||||
cache := NewChunkCache()
|
cache := NewChunkCache()
|
||||||
chunk1p := &Chunk{}
|
chunk1p := &Chunk{}
|
||||||
chunk2p := &Chunk{count: chunkSize}
|
chunk2p := &Chunk{count: chunkSize}
|
||||||
items1 := []Result{{}}
|
bm1 := ChunkBitmap{1}
|
||||||
items2 := []Result{{}, {}}
|
bm2 := ChunkBitmap{1, 2}
|
||||||
cache.Add(chunk1p, "foo", items1)
|
cache.Add(chunk1p, "foo", bm1, 1)
|
||||||
cache.Add(chunk2p, "foo", items1)
|
cache.Add(chunk2p, "foo", bm1, 1)
|
||||||
cache.Add(chunk2p, "bar", items2)
|
cache.Add(chunk2p, "bar", bm2, 2)
|
||||||
|
|
||||||
{ // chunk1 is not full
|
{ // chunk1 is not full
|
||||||
cached := cache.Lookup(chunk1p, "foo")
|
cached := cache.Lookup(chunk1p, "foo")
|
||||||
if cached != nil {
|
if cached != nil {
|
||||||
t.Error("Cached disabled for non-empty chunks", cached)
|
t.Error("Cached disabled for non-full chunks", cached)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
cached := cache.Lookup(chunk2p, "foo")
|
cached := cache.Lookup(chunk2p, "foo")
|
||||||
if cached == nil || len(cached) != 1 {
|
if cached == nil || cached[0] != 1 {
|
||||||
t.Error("Expected 1 item cached", cached)
|
t.Error("Expected bitmap cached", cached)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
cached := cache.Lookup(chunk2p, "bar")
|
cached := cache.Lookup(chunk2p, "bar")
|
||||||
if cached == nil || len(cached) != 2 {
|
if cached == nil || cached[1] != 2 {
|
||||||
t.Error("Expected 2 items cached", cached)
|
t.Error("Expected bitmap cached", cached)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
cached := cache.Lookup(chunk1p, "foobar")
|
cached := cache.Lookup(chunk1p, "foobar")
|
||||||
if cached != nil {
|
if cached != nil {
|
||||||
t.Error("Expected 0 item cached", cached)
|
t.Error("Expected nil cached", cached)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,20 @@ func (cl *ChunkList) lastChunk() *Chunk {
|
|||||||
return cl.chunks[len(cl.chunks)-1]
|
return cl.chunks[len(cl.chunks)-1]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetItems returns the first n items from the given chunks
|
||||||
|
func GetItems(chunks []*Chunk, n int) []Item {
|
||||||
|
items := make([]Item, 0, n)
|
||||||
|
for _, chunk := range chunks {
|
||||||
|
for i := 0; i < chunk.count && len(items) < n; i++ {
|
||||||
|
items = append(items, chunk.items[i])
|
||||||
|
}
|
||||||
|
if len(items) >= n {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
// CountItems returns the total number of Items
|
// CountItems returns the total number of Items
|
||||||
func CountItems(cs []*Chunk) int {
|
func CountItems(cs []*Chunk) int {
|
||||||
if len(cs) == 0 {
|
if len(cs) == 0 {
|
||||||
@@ -85,6 +99,21 @@ func (cl *ChunkList) Clear() {
|
|||||||
cl.mutex.Unlock()
|
cl.mutex.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ForEachItem iterates all items and applies fn to each one.
|
||||||
|
// The done callback runs under the lock to safely update shared state.
|
||||||
|
func (cl *ChunkList) ForEachItem(fn func(*Item), done func()) {
|
||||||
|
cl.mutex.Lock()
|
||||||
|
for _, chunk := range cl.chunks {
|
||||||
|
for i := 0; i < chunk.count; i++ {
|
||||||
|
fn(&chunk.items[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if done != nil {
|
||||||
|
done()
|
||||||
|
}
|
||||||
|
cl.mutex.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
// Snapshot returns immutable snapshot of the ChunkList
|
// Snapshot returns immutable snapshot of the ChunkList
|
||||||
func (cl *ChunkList) Snapshot(tail int) ([]*Chunk, int, bool) {
|
func (cl *ChunkList) Snapshot(tail int) ([]*Chunk, int, bool) {
|
||||||
cl.mutex.Lock()
|
cl.mutex.Lock()
|
||||||
|
|||||||
+3
-5
@@ -34,19 +34,18 @@ const (
|
|||||||
maxBgProcessesPerAction = 3
|
maxBgProcessesPerAction = 3
|
||||||
|
|
||||||
// Matcher
|
// Matcher
|
||||||
numPartitionsMultiplier = 8
|
|
||||||
maxPartitions = 32
|
|
||||||
progressMinDuration = 200 * time.Millisecond
|
progressMinDuration = 200 * time.Millisecond
|
||||||
|
|
||||||
// Capacity of each chunk
|
// Capacity of each chunk
|
||||||
chunkSize int = 100
|
chunkSize int = 1024
|
||||||
|
chunkBitWords = (chunkSize + 63) / 64
|
||||||
|
|
||||||
// Pre-allocated memory slices to minimize GC
|
// Pre-allocated memory slices to minimize GC
|
||||||
slab16Size int = 100 * 1024 // 200KB * 32 = 12.8MB
|
slab16Size int = 100 * 1024 // 200KB * 32 = 12.8MB
|
||||||
slab32Size int = 2048 // 8KB * 32 = 256KB
|
slab32Size int = 2048 // 8KB * 32 = 256KB
|
||||||
|
|
||||||
// Do not cache results of low selectivity queries
|
// Do not cache results of low selectivity queries
|
||||||
queryCacheMax int = chunkSize / 5
|
queryCacheMax int = chunkSize / 2
|
||||||
|
|
||||||
// Not to cache mergers with large lists
|
// Not to cache mergers with large lists
|
||||||
mergerCacheMax int = 100000
|
mergerCacheMax int = 100000
|
||||||
@@ -65,7 +64,6 @@ const (
|
|||||||
EvtSearchNew
|
EvtSearchNew
|
||||||
EvtSearchProgress
|
EvtSearchProgress
|
||||||
EvtSearchFin
|
EvtSearchFin
|
||||||
EvtHeader
|
|
||||||
EvtReady
|
EvtReady
|
||||||
EvtQuit
|
EvtQuit
|
||||||
)
|
)
|
||||||
|
|||||||
+140
-36
@@ -2,7 +2,9 @@
|
|||||||
package fzf
|
package fzf
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -17,7 +19,6 @@ Reader -> EvtReadNew -> Matcher (restart)
|
|||||||
Terminal -> EvtSearchNew:bool -> Matcher (restart)
|
Terminal -> EvtSearchNew:bool -> Matcher (restart)
|
||||||
Matcher -> EvtSearchProgress -> Terminal (update info)
|
Matcher -> EvtSearchProgress -> Terminal (update info)
|
||||||
Matcher -> EvtSearchFin -> Terminal (update list)
|
Matcher -> EvtSearchFin -> Terminal (update list)
|
||||||
Matcher -> EvtHeader -> Terminal (update header)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type revision struct {
|
type revision struct {
|
||||||
@@ -56,6 +57,9 @@ func Run(opts *Options) (int, error) {
|
|||||||
if opts.useTmux() {
|
if opts.useTmux() {
|
||||||
return runTmux(os.Args, opts)
|
return runTmux(os.Args, opts)
|
||||||
}
|
}
|
||||||
|
if opts.useZellij() {
|
||||||
|
return runZellij(os.Args, opts)
|
||||||
|
}
|
||||||
|
|
||||||
if needWinpty(opts) {
|
if needWinpty(opts) {
|
||||||
return runWinpty(os.Args, opts)
|
return runWinpty(os.Args, opts)
|
||||||
@@ -113,22 +117,9 @@ func Run(opts *Options) (int, error) {
|
|||||||
cache := NewChunkCache()
|
cache := NewChunkCache()
|
||||||
var chunkList *ChunkList
|
var chunkList *ChunkList
|
||||||
var itemIndex int32
|
var itemIndex int32
|
||||||
header := make([]string, 0, opts.HeaderLines)
|
// transformItem applies with-nth transformation to an item's raw data.
|
||||||
if opts.WithNth == nil {
|
// It handles ANSI token propagation using prevLineAnsiState for cross-line continuity.
|
||||||
chunkList = NewChunkList(cache, func(item *Item, data []byte) bool {
|
transformItem := func(item *Item, data []byte, transformer func([]Token, int32) string, index int32) {
|
||||||
if len(header) < opts.HeaderLines {
|
|
||||||
header = append(header, byteString(data))
|
|
||||||
eventBox.Set(EvtHeader, header)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
item.text, item.colors = ansiProcessor(data)
|
|
||||||
item.text.Index = itemIndex
|
|
||||||
itemIndex++
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
nthTransformer := opts.WithNth(opts.Delimiter)
|
|
||||||
chunkList = NewChunkList(cache, func(item *Item, data []byte) bool {
|
|
||||||
tokens := Tokenize(byteString(data), opts.Delimiter)
|
tokens := Tokenize(byteString(data), opts.Delimiter)
|
||||||
if opts.Ansi && len(tokens) > 1 {
|
if opts.Ansi && len(tokens) > 1 {
|
||||||
var ansiState *ansiState
|
var ansiState *ansiState
|
||||||
@@ -146,12 +137,7 @@ func Run(opts *Options) (int, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
transformed := nthTransformer(tokens, itemIndex)
|
transformed := transformer(tokens, index)
|
||||||
if len(header) < opts.HeaderLines {
|
|
||||||
header = append(header, transformed)
|
|
||||||
eventBox.Set(EvtHeader, header)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
item.text, item.colors = ansiProcessor(stringBytes(transformed))
|
item.text, item.colors = ansiProcessor(stringBytes(transformed))
|
||||||
|
|
||||||
// We should not trim trailing whitespaces with background colors
|
// We should not trim trailing whitespaces with background colors
|
||||||
@@ -164,6 +150,24 @@ func Run(opts *Options) (int, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
item.text.TrimTrailingWhitespaces(int(maxColorOffset))
|
item.text.TrimTrailingWhitespaces(int(maxColorOffset))
|
||||||
|
}
|
||||||
|
|
||||||
|
var nthTransformer func([]Token, int32) string
|
||||||
|
if opts.WithNth == nil {
|
||||||
|
chunkList = NewChunkList(cache, func(item *Item, data []byte) bool {
|
||||||
|
item.text, item.colors = ansiProcessor(data)
|
||||||
|
item.text.Index = itemIndex
|
||||||
|
itemIndex++
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
nthTransformer = opts.WithNth(opts.Delimiter)
|
||||||
|
chunkList = NewChunkList(cache, func(item *Item, data []byte) bool {
|
||||||
|
if nthTransformer == nil {
|
||||||
|
item.text, item.colors = ansiProcessor(data)
|
||||||
|
} else {
|
||||||
|
transformItem(item, data, nthTransformer, itemIndex)
|
||||||
|
}
|
||||||
item.text.Index = itemIndex
|
item.text.Index = itemIndex
|
||||||
item.origText = &data
|
item.origText = &data
|
||||||
itemIndex++
|
itemIndex++
|
||||||
@@ -193,13 +197,15 @@ func Run(opts *Options) (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reader
|
// Reader
|
||||||
streamingFilter := opts.Filter != nil && !sort && !opts.Tac && !opts.Sync
|
streamingFilter := opts.Filter != nil && !sort && !opts.Tac && !opts.Sync && opts.Bench == 0
|
||||||
var reader *Reader
|
var reader *Reader
|
||||||
|
var ingestionStart time.Time
|
||||||
if !streamingFilter {
|
if !streamingFilter {
|
||||||
reader = NewReader(func(data []byte) bool {
|
reader = NewReader(func(data []byte) bool {
|
||||||
return chunkList.Push(data)
|
return chunkList.Push(data)
|
||||||
}, eventBox, executor, opts.ReadZero, opts.Filter == nil)
|
}, eventBox, executor, opts.ReadZero, opts.Filter == nil)
|
||||||
|
|
||||||
|
ingestionStart = time.Now()
|
||||||
readyChan := make(chan bool)
|
readyChan := make(chan bool)
|
||||||
go reader.ReadSource(opts.Input, opts.WalkerRoot, opts.WalkerOpts, opts.WalkerSkip, initialReload, initialEnv, readyChan)
|
go reader.ReadSource(opts.Input, opts.WalkerRoot, opts.WalkerOpts, opts.WalkerSkip, initialReload, initialEnv, readyChan)
|
||||||
<-readyChan
|
<-readyChan
|
||||||
@@ -236,15 +242,20 @@ func Run(opts *Options) (int, error) {
|
|||||||
denylist = make(map[int32]struct{})
|
denylist = make(map[int32]struct{})
|
||||||
denyMutex.Unlock()
|
denyMutex.Unlock()
|
||||||
}
|
}
|
||||||
|
if opts.HeaderLines > math.MaxInt32 {
|
||||||
|
opts.HeaderLines = math.MaxInt32
|
||||||
|
}
|
||||||
|
headerLines := int32(opts.HeaderLines)
|
||||||
|
headerUpdated := false
|
||||||
patternBuilder := func(runes []rune) *Pattern {
|
patternBuilder := func(runes []rune) *Pattern {
|
||||||
denyMutex.Lock()
|
denyMutex.Lock()
|
||||||
denylistCopy := maps.Clone(denylist)
|
denylistCopy := maps.Clone(denylist)
|
||||||
denyMutex.Unlock()
|
denyMutex.Unlock()
|
||||||
return BuildPattern(cache, patternCache,
|
return BuildPattern(cache, patternCache,
|
||||||
opts.Fuzzy, opts.FuzzyAlgo, opts.Extended, opts.Case, opts.Normalize, forward, withPos,
|
opts.Fuzzy, opts.FuzzyAlgo, opts.Extended, opts.Case, opts.Normalize, forward, withPos,
|
||||||
opts.Filter == nil, nth, opts.Delimiter, inputRevision, runes, denylistCopy)
|
opts.Filter == nil, nth, opts.Delimiter, inputRevision, runes, denylistCopy, headerLines)
|
||||||
}
|
}
|
||||||
matcher := NewMatcher(cache, patternBuilder, sort, opts.Tac, eventBox, inputRevision)
|
matcher := NewMatcher(cache, patternBuilder, sort, opts.Tac, eventBox, inputRevision, opts.Threads)
|
||||||
|
|
||||||
// Filtering mode
|
// Filtering mode
|
||||||
if opts.Filter != nil {
|
if opts.Filter != nil {
|
||||||
@@ -265,8 +276,11 @@ func Run(opts *Options) (int, error) {
|
|||||||
func(runes []byte) bool {
|
func(runes []byte) bool {
|
||||||
item := Item{}
|
item := Item{}
|
||||||
if chunkList.trans(&item, runes) {
|
if chunkList.trans(&item, runes) {
|
||||||
|
if item.Index() < headerLines {
|
||||||
|
return false
|
||||||
|
}
|
||||||
mutex.Lock()
|
mutex.Lock()
|
||||||
if result, _, _ := pattern.MatchItem(&item, false, slab); result != nil {
|
if result, _, _ := pattern.MatchItem(&item, false, slab); result.item != nil {
|
||||||
opts.Printer(transformer(&item))
|
opts.Printer(transformer(&item))
|
||||||
found = true
|
found = true
|
||||||
}
|
}
|
||||||
@@ -278,9 +292,51 @@ func Run(opts *Options) (int, error) {
|
|||||||
} else {
|
} else {
|
||||||
eventBox.Unwatch(EvtReadNew)
|
eventBox.Unwatch(EvtReadNew)
|
||||||
eventBox.WaitFor(EvtReadFin)
|
eventBox.WaitFor(EvtReadFin)
|
||||||
|
ingestionTime := time.Since(ingestionStart)
|
||||||
|
|
||||||
// NOTE: Streaming filter is inherently not compatible with --tail
|
// NOTE: Streaming filter is inherently not compatible with --tail
|
||||||
snapshot, _, _ := chunkList.Snapshot(opts.Tail)
|
snapshot, _, _ := chunkList.Snapshot(opts.Tail)
|
||||||
|
|
||||||
|
if opts.Bench > 0 {
|
||||||
|
// Benchmark mode: repeat scan for the given duration
|
||||||
|
totalItems := CountItems(snapshot)
|
||||||
|
var matchCount int
|
||||||
|
var times []time.Duration
|
||||||
|
deadline := time.Now().Add(opts.Bench)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
cache.Clear()
|
||||||
|
start := time.Now()
|
||||||
|
result := matcher.scan(MatchRequest{
|
||||||
|
chunks: snapshot,
|
||||||
|
pattern: pattern})
|
||||||
|
times = append(times, time.Since(start))
|
||||||
|
matchCount = result.merger.Length()
|
||||||
|
}
|
||||||
|
// Print stats
|
||||||
|
var total time.Duration
|
||||||
|
minD, maxD := times[0], times[0]
|
||||||
|
for _, d := range times {
|
||||||
|
total += d
|
||||||
|
if d < minD {
|
||||||
|
minD = d
|
||||||
|
}
|
||||||
|
if d > maxD {
|
||||||
|
maxD = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
avg := total / time.Duration(len(times))
|
||||||
|
selectivity := float64(matchCount) / float64(totalItems) * 100
|
||||||
|
fmt.Printf(" %d iterations avg: %.2fms min: %.2fms max: %.2fms total: %.2fs items: %d matches: %d (%.2f%%) ingestion: %.2fms\n",
|
||||||
|
len(times),
|
||||||
|
float64(avg.Microseconds())/1000,
|
||||||
|
float64(minD.Microseconds())/1000,
|
||||||
|
float64(maxD.Microseconds())/1000,
|
||||||
|
total.Seconds(),
|
||||||
|
totalItems, matchCount, selectivity,
|
||||||
|
float64(ingestionTime.Microseconds())/1000)
|
||||||
|
return ExitOk, nil
|
||||||
|
}
|
||||||
|
|
||||||
result := matcher.scan(MatchRequest{
|
result := matcher.scan(MatchRequest{
|
||||||
chunks: snapshot,
|
chunks: snapshot,
|
||||||
pattern: pattern})
|
pattern: pattern})
|
||||||
@@ -330,10 +386,11 @@ func Run(opts *Options) (int, error) {
|
|||||||
query := []rune{}
|
query := []rune{}
|
||||||
determine := func(final bool) {
|
determine := func(final bool) {
|
||||||
if heightUnknown {
|
if heightUnknown {
|
||||||
if total >= maxFit || final {
|
items := max(0, total-int(headerLines))
|
||||||
|
if items >= maxFit || final {
|
||||||
deferred = false
|
deferred = false
|
||||||
heightUnknown = false
|
heightUnknown = false
|
||||||
terminal.startChan <- fitpad{min(total, maxFit), padHeight}
|
terminal.startChan <- fitpad{min(items, maxFit), padHeight}
|
||||||
}
|
}
|
||||||
} else if deferred {
|
} else if deferred {
|
||||||
deferred = false
|
deferred = false
|
||||||
@@ -349,11 +406,11 @@ func Run(opts *Options) (int, error) {
|
|||||||
clearDenylist()
|
clearDenylist()
|
||||||
}
|
}
|
||||||
reading = true
|
reading = true
|
||||||
|
headerUpdated = false
|
||||||
startTick = ticks
|
startTick = ticks
|
||||||
chunkList.Clear()
|
chunkList.Clear()
|
||||||
itemIndex = 0
|
itemIndex = 0
|
||||||
inputRevision.bumpMajor()
|
inputRevision.bumpMajor()
|
||||||
header = make([]string, 0, opts.HeaderLines)
|
|
||||||
readyChan := make(chan bool)
|
readyChan := make(chan bool)
|
||||||
go reader.restart(command, environ, readyChan)
|
go reader.restart(command, environ, readyChan)
|
||||||
<-readyChan
|
<-readyChan
|
||||||
@@ -411,16 +468,24 @@ func Run(opts *Options) (int, error) {
|
|||||||
snapshotRevision = inputRevision
|
snapshotRevision = inputRevision
|
||||||
}
|
}
|
||||||
total = count
|
total = count
|
||||||
terminal.UpdateCount(total, !reading, value.(*string))
|
terminal.UpdateCount(max(0, total-int(headerLines)), !reading, value.(*string))
|
||||||
|
if headerLines > 0 && !headerUpdated {
|
||||||
|
terminal.UpdateHeader(GetItems(snapshot, int(headerLines)))
|
||||||
|
headerUpdated = total >= int(headerLines)
|
||||||
|
}
|
||||||
if heightUnknown && !deferred {
|
if heightUnknown && !deferred {
|
||||||
determine(!reading)
|
determine(!reading)
|
||||||
}
|
}
|
||||||
|
if !useSnapshot || evt == EvtReadFin {
|
||||||
matcher.Reset(snapshot, input(), false, !reading, sort, snapshotRevision)
|
matcher.Reset(snapshot, input(), false, !reading, sort, snapshotRevision)
|
||||||
|
}
|
||||||
|
|
||||||
case EvtSearchNew:
|
case EvtSearchNew:
|
||||||
var command *commandSpec
|
var command *commandSpec
|
||||||
var environ []string
|
var environ []string
|
||||||
var changed bool
|
var changed bool
|
||||||
|
headerLinesChanged := false
|
||||||
|
withNthChanged := false
|
||||||
switch val := value.(type) {
|
switch val := value.(type) {
|
||||||
case searchRequest:
|
case searchRequest:
|
||||||
sort = val.sort
|
sort = val.sort
|
||||||
@@ -441,6 +506,40 @@ func Run(opts *Options) (int, error) {
|
|||||||
nth = *val.nth
|
nth = *val.nth
|
||||||
bump = true
|
bump = true
|
||||||
}
|
}
|
||||||
|
if val.headerLines != nil {
|
||||||
|
headerLines = int32(*val.headerLines)
|
||||||
|
headerUpdated = false
|
||||||
|
headerLinesChanged = true
|
||||||
|
bump = true
|
||||||
|
}
|
||||||
|
if val.withNth != nil {
|
||||||
|
newTransformer := val.withNth.fn
|
||||||
|
// Cancel any in-flight scan and block the terminal from reading
|
||||||
|
// items before mutating them in-place. Snapshot shares middle
|
||||||
|
// chunk pointers, so the matcher and terminal can race with us.
|
||||||
|
matcher.CancelScan()
|
||||||
|
terminal.PauseRendering()
|
||||||
|
// Reset cross-line ANSI state before re-processing all items
|
||||||
|
lineAnsiState = nil
|
||||||
|
prevLineAnsiState = nil
|
||||||
|
chunkList.ForEachItem(func(item *Item) {
|
||||||
|
origBytes := *item.origText
|
||||||
|
savedIndex := item.Index()
|
||||||
|
if newTransformer != nil {
|
||||||
|
transformItem(item, origBytes, newTransformer, savedIndex)
|
||||||
|
} else {
|
||||||
|
item.text, item.colors = ansiProcessor(origBytes)
|
||||||
|
}
|
||||||
|
item.text.Index = savedIndex
|
||||||
|
item.transformed = nil
|
||||||
|
}, func() {
|
||||||
|
nthTransformer = newTransformer
|
||||||
|
})
|
||||||
|
terminal.ResumeRendering()
|
||||||
|
matcher.ResumeScan()
|
||||||
|
withNthChanged = true
|
||||||
|
bump = true
|
||||||
|
}
|
||||||
if bump {
|
if bump {
|
||||||
patternCache = make(map[string]*Pattern)
|
patternCache = make(map[string]*Pattern)
|
||||||
cache.Clear()
|
cache.Clear()
|
||||||
@@ -477,6 +576,16 @@ func Run(opts *Options) (int, error) {
|
|||||||
snapshotRevision = inputRevision
|
snapshotRevision = inputRevision
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if headerLinesChanged {
|
||||||
|
terminal.UpdateCount(max(0, total-int(headerLines)), !reading, nil)
|
||||||
|
if headerLines > 0 {
|
||||||
|
terminal.UpdateHeader(GetItems(snapshot, int(headerLines)))
|
||||||
|
} else {
|
||||||
|
terminal.UpdateHeader(nil)
|
||||||
|
}
|
||||||
|
} else if withNthChanged && headerLines > 0 {
|
||||||
|
terminal.UpdateHeader(GetItems(snapshot, int(headerLines)))
|
||||||
|
}
|
||||||
matcher.Reset(snapshot, input(), true, !reading, sort, snapshotRevision)
|
matcher.Reset(snapshot, input(), true, !reading, sort, snapshotRevision)
|
||||||
delay = false
|
delay = false
|
||||||
|
|
||||||
@@ -486,11 +595,6 @@ func Run(opts *Options) (int, error) {
|
|||||||
terminal.UpdateProgress(val)
|
terminal.UpdateProgress(val)
|
||||||
}
|
}
|
||||||
|
|
||||||
case EvtHeader:
|
|
||||||
headerPadded := make([]string, opts.HeaderLines)
|
|
||||||
copy(headerPadded, value.([]string))
|
|
||||||
terminal.UpdateHeader(headerPadded)
|
|
||||||
|
|
||||||
case EvtSearchFin:
|
case EvtSearchFin:
|
||||||
switch val := value.(type) {
|
switch val := value.(type) {
|
||||||
case MatchResult:
|
case MatchResult:
|
||||||
|
|||||||
+50
-53
@@ -3,8 +3,8 @@ package fzf
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sort"
|
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/junegunn/fzf/src/util"
|
"github.com/junegunn/fzf/src/util"
|
||||||
@@ -43,8 +43,11 @@ type Matcher struct {
|
|||||||
reqBox *util.EventBox
|
reqBox *util.EventBox
|
||||||
partitions int
|
partitions int
|
||||||
slab []*util.Slab
|
slab []*util.Slab
|
||||||
|
sortBuf [][]Result
|
||||||
mergerCache map[string]MatchResult
|
mergerCache map[string]MatchResult
|
||||||
revision revision
|
revision revision
|
||||||
|
scanMutex sync.Mutex
|
||||||
|
cancelScan *util.AtomicBool
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -54,8 +57,11 @@ const (
|
|||||||
|
|
||||||
// NewMatcher returns a new Matcher
|
// NewMatcher returns a new Matcher
|
||||||
func NewMatcher(cache *ChunkCache, patternBuilder func([]rune) *Pattern,
|
func NewMatcher(cache *ChunkCache, patternBuilder func([]rune) *Pattern,
|
||||||
sort bool, tac bool, eventBox *util.EventBox, revision revision) *Matcher {
|
sort bool, tac bool, eventBox *util.EventBox, revision revision, threads int) *Matcher {
|
||||||
partitions := min(numPartitionsMultiplier*runtime.NumCPU(), maxPartitions)
|
partitions := runtime.NumCPU()
|
||||||
|
if threads > 0 {
|
||||||
|
partitions = threads
|
||||||
|
}
|
||||||
return &Matcher{
|
return &Matcher{
|
||||||
cache: cache,
|
cache: cache,
|
||||||
patternBuilder: patternBuilder,
|
patternBuilder: patternBuilder,
|
||||||
@@ -65,8 +71,10 @@ func NewMatcher(cache *ChunkCache, patternBuilder func([]rune) *Pattern,
|
|||||||
reqBox: util.NewEventBox(),
|
reqBox: util.NewEventBox(),
|
||||||
partitions: partitions,
|
partitions: partitions,
|
||||||
slab: make([]*util.Slab, partitions),
|
slab: make([]*util.Slab, partitions),
|
||||||
|
sortBuf: make([][]Result, partitions),
|
||||||
mergerCache: make(map[string]MatchResult),
|
mergerCache: make(map[string]MatchResult),
|
||||||
revision: revision}
|
revision: revision,
|
||||||
|
cancelScan: util.NewAtomicBool(false)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loop puts Matcher in action
|
// Loop puts Matcher in action
|
||||||
@@ -126,7 +134,9 @@ func (m *Matcher) Loop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if result.merger == nil {
|
if result.merger == nil {
|
||||||
|
m.scanMutex.Lock()
|
||||||
result = m.scan(request)
|
result = m.scan(request)
|
||||||
|
m.scanMutex.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
if !result.cancelled {
|
if !result.cancelled {
|
||||||
@@ -139,27 +149,6 @@ func (m *Matcher) Loop() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Matcher) sliceChunks(chunks []*Chunk) [][]*Chunk {
|
|
||||||
partitions := m.partitions
|
|
||||||
perSlice := len(chunks) / partitions
|
|
||||||
|
|
||||||
if perSlice == 0 {
|
|
||||||
partitions = len(chunks)
|
|
||||||
perSlice = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
slices := make([][]*Chunk, partitions)
|
|
||||||
for i := 0; i < partitions; i++ {
|
|
||||||
start := i * perSlice
|
|
||||||
end := start + perSlice
|
|
||||||
if i == partitions-1 {
|
|
||||||
end = len(chunks)
|
|
||||||
}
|
|
||||||
slices[i] = chunks[start:end]
|
|
||||||
}
|
|
||||||
return slices
|
|
||||||
}
|
|
||||||
|
|
||||||
type partialResult struct {
|
type partialResult struct {
|
||||||
index int
|
index int
|
||||||
matches []Result
|
matches []Result
|
||||||
@@ -174,7 +163,7 @@ func (m *Matcher) scan(request MatchRequest) MatchResult {
|
|||||||
return MatchResult{m, m, false}
|
return MatchResult{m, m, false}
|
||||||
}
|
}
|
||||||
pattern := request.pattern
|
pattern := request.pattern
|
||||||
passMerger := PassMerger(&request.chunks, m.tac, request.revision)
|
passMerger := PassMerger(&request.chunks, m.tac, request.revision, pattern.startIndex)
|
||||||
if pattern.IsEmpty() {
|
if pattern.IsEmpty() {
|
||||||
return MatchResult{passMerger, passMerger, false}
|
return MatchResult{passMerger, passMerger, false}
|
||||||
}
|
}
|
||||||
@@ -183,43 +172,37 @@ func (m *Matcher) scan(request MatchRequest) MatchResult {
|
|||||||
maxIndex := request.chunks[numChunks-1].lastIndex(minIndex)
|
maxIndex := request.chunks[numChunks-1].lastIndex(minIndex)
|
||||||
cancelled := util.NewAtomicBool(false)
|
cancelled := util.NewAtomicBool(false)
|
||||||
|
|
||||||
slices := m.sliceChunks(request.chunks)
|
numWorkers := min(m.partitions, numChunks)
|
||||||
numSlices := len(slices)
|
var nextChunk atomic.Int32
|
||||||
resultChan := make(chan partialResult, numSlices)
|
resultChan := make(chan partialResult, numWorkers)
|
||||||
countChan := make(chan int, numChunks)
|
countChan := make(chan int, numChunks)
|
||||||
waitGroup := sync.WaitGroup{}
|
waitGroup := sync.WaitGroup{}
|
||||||
|
|
||||||
for idx, chunks := range slices {
|
for idx := range numWorkers {
|
||||||
waitGroup.Add(1)
|
waitGroup.Add(1)
|
||||||
if m.slab[idx] == nil {
|
if m.slab[idx] == nil {
|
||||||
m.slab[idx] = util.MakeSlab(slab16Size, slab32Size)
|
m.slab[idx] = util.MakeSlab(slab16Size, slab32Size)
|
||||||
}
|
}
|
||||||
go func(idx int, slab *util.Slab, chunks []*Chunk) {
|
go func(idx int, slab *util.Slab) {
|
||||||
defer func() { waitGroup.Done() }()
|
defer waitGroup.Done()
|
||||||
count := 0
|
var matches []Result
|
||||||
allMatches := make([][]Result, len(chunks))
|
for {
|
||||||
for idx, chunk := range chunks {
|
ci := int(nextChunk.Add(1)) - 1
|
||||||
matches := request.pattern.Match(chunk, slab)
|
if ci >= numChunks {
|
||||||
allMatches[idx] = matches
|
break
|
||||||
count += len(matches)
|
}
|
||||||
|
chunkMatches := request.pattern.Match(request.chunks[ci], slab)
|
||||||
|
matches = append(matches, chunkMatches...)
|
||||||
if cancelled.Get() {
|
if cancelled.Get() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
countChan <- len(matches)
|
countChan <- len(chunkMatches)
|
||||||
}
|
|
||||||
sliceMatches := make([]Result, 0, count)
|
|
||||||
for _, matches := range allMatches {
|
|
||||||
sliceMatches = append(sliceMatches, matches...)
|
|
||||||
}
|
}
|
||||||
if m.sort && request.pattern.sortable {
|
if m.sort && request.pattern.sortable {
|
||||||
if m.tac {
|
m.sortBuf[idx] = radixSortResults(matches, m.tac, m.sortBuf[idx])
|
||||||
sort.Sort(ByRelevanceTac(sliceMatches))
|
|
||||||
} else {
|
|
||||||
sort.Sort(ByRelevance(sliceMatches))
|
|
||||||
}
|
}
|
||||||
}
|
resultChan <- partialResult{idx, matches}
|
||||||
resultChan <- partialResult{idx, sliceMatches}
|
}(idx, m.slab[idx])
|
||||||
}(idx, m.slab[idx], chunks)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
wait := func() bool {
|
wait := func() bool {
|
||||||
@@ -238,7 +221,7 @@ func (m *Matcher) scan(request MatchRequest) MatchResult {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.reqBox.Peek(reqReset) {
|
if m.cancelScan.Get() || m.reqBox.Peek(reqReset) {
|
||||||
return MatchResult{nil, nil, wait()}
|
return MatchResult{nil, nil, wait()}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,8 +230,8 @@ func (m *Matcher) scan(request MatchRequest) MatchResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
partialResults := make([][]Result, numSlices)
|
partialResults := make([][]Result, numWorkers)
|
||||||
for range slices {
|
for range numWorkers {
|
||||||
partialResult := <-resultChan
|
partialResult := <-resultChan
|
||||||
partialResults[partialResult.index] = partialResult.matches
|
partialResults[partialResult.index] = partialResult.matches
|
||||||
}
|
}
|
||||||
@@ -269,6 +252,20 @@ func (m *Matcher) Reset(chunks []*Chunk, patternRunes []rune, cancel bool, final
|
|||||||
m.reqBox.Set(event, MatchRequest{chunks, pattern, final, sort, revision})
|
m.reqBox.Set(event, MatchRequest{chunks, pattern, final, sort, revision})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CancelScan cancels any in-flight scan, waits for it to finish,
|
||||||
|
// and prevents new scans from starting until ResumeScan is called.
|
||||||
|
// This is used to safely mutate shared items (e.g., during with-nth changes).
|
||||||
|
func (m *Matcher) CancelScan() {
|
||||||
|
m.cancelScan.Set(true)
|
||||||
|
m.scanMutex.Lock()
|
||||||
|
m.cancelScan.Set(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResumeScan allows scans to proceed again after CancelScan.
|
||||||
|
func (m *Matcher) ResumeScan() {
|
||||||
|
m.scanMutex.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Matcher) Stop() {
|
func (m *Matcher) Stop() {
|
||||||
m.reqBox.Set(reqQuit, nil)
|
m.reqBox.Set(reqQuit, nil)
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-12
@@ -20,32 +20,36 @@ type Merger struct {
|
|||||||
final bool
|
final bool
|
||||||
count int
|
count int
|
||||||
pass bool
|
pass bool
|
||||||
|
startIndex int
|
||||||
revision revision
|
revision revision
|
||||||
minIndex int32
|
minIndex int32
|
||||||
maxIndex int32
|
maxIndex int32
|
||||||
}
|
}
|
||||||
|
|
||||||
// PassMerger returns a new Merger that simply returns the items in the
|
// PassMerger returns a new Merger that simply returns the items in the
|
||||||
// original order
|
// original order. startIndex items are skipped from the beginning.
|
||||||
func PassMerger(chunks *[]*Chunk, tac bool, revision revision) *Merger {
|
func PassMerger(chunks *[]*Chunk, tac bool, revision revision, startIndex int32) *Merger {
|
||||||
var minIndex, maxIndex int32
|
var minIndex, maxIndex int32
|
||||||
if len(*chunks) > 0 {
|
if len(*chunks) > 0 {
|
||||||
minIndex = (*chunks)[0].items[0].Index()
|
minIndex = (*chunks)[0].items[0].Index()
|
||||||
maxIndex = (*chunks)[len(*chunks)-1].lastIndex(minIndex)
|
maxIndex = (*chunks)[len(*chunks)-1].lastIndex(minIndex)
|
||||||
}
|
}
|
||||||
|
si := int(startIndex)
|
||||||
mg := Merger{
|
mg := Merger{
|
||||||
pattern: nil,
|
pattern: nil,
|
||||||
chunks: chunks,
|
chunks: chunks,
|
||||||
tac: tac,
|
tac: tac,
|
||||||
count: 0,
|
count: 0,
|
||||||
pass: true,
|
pass: true,
|
||||||
|
startIndex: si,
|
||||||
revision: revision,
|
revision: revision,
|
||||||
minIndex: minIndex,
|
minIndex: minIndex + startIndex,
|
||||||
maxIndex: maxIndex}
|
maxIndex: maxIndex}
|
||||||
|
|
||||||
for _, chunk := range *mg.chunks {
|
for _, chunk := range *mg.chunks {
|
||||||
mg.count += chunk.count
|
mg.count += chunk.count
|
||||||
}
|
}
|
||||||
|
mg.count = max(0, mg.count-si)
|
||||||
return &mg
|
return &mg
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +117,7 @@ func (mg *Merger) Get(idx int) Result {
|
|||||||
if mg.tac {
|
if mg.tac {
|
||||||
idx = mg.count - idx - 1
|
idx = mg.count - idx - 1
|
||||||
}
|
}
|
||||||
|
idx += mg.startIndex
|
||||||
firstChunk := (*mg.chunks)[0]
|
firstChunk := (*mg.chunks)[0]
|
||||||
if firstChunk.count < chunkSize && idx >= firstChunk.count {
|
if firstChunk.count < chunkSize && idx >= firstChunk.count {
|
||||||
idx -= firstChunk.count
|
idx -= firstChunk.count
|
||||||
@@ -131,14 +136,7 @@ func (mg *Merger) Get(idx int) Result {
|
|||||||
if mg.tac {
|
if mg.tac {
|
||||||
idx = mg.count - idx - 1
|
idx = mg.count - idx - 1
|
||||||
}
|
}
|
||||||
for _, list := range mg.lists {
|
return mg.mergedGet(idx)
|
||||||
numItems := len(list)
|
|
||||||
if idx < numItems {
|
|
||||||
return list[idx]
|
|
||||||
}
|
|
||||||
idx -= numItems
|
|
||||||
}
|
|
||||||
panic(fmt.Sprintf("Index out of bounds (unsorted, %d/%d)", idx, mg.count))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mg *Merger) ToMap() map[int32]Result {
|
func (mg *Merger) ToMap() map[int32]Result {
|
||||||
@@ -166,7 +164,7 @@ func (mg *Merger) mergedGet(idx int) Result {
|
|||||||
}
|
}
|
||||||
if cursor >= 0 {
|
if cursor >= 0 {
|
||||||
rank := list[cursor]
|
rank := list[cursor]
|
||||||
if minIdx < 0 || compareRanks(rank, minRank, mg.tac) {
|
if minIdx < 0 || mg.sorted && compareRanks(rank, minRank, mg.tac) || !mg.sorted && rank.item.Index() < minRank.item.Index() {
|
||||||
minRank = rank
|
minRank = rank
|
||||||
minIdx = listIdx
|
minIdx = listIdx
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-2
@@ -54,10 +54,25 @@ func buildLists(partiallySorted bool) ([][]Result, []Result) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestMergerUnsorted(t *testing.T) {
|
func TestMergerUnsorted(t *testing.T) {
|
||||||
lists, items := buildLists(false)
|
lists, _ := buildLists(false)
|
||||||
|
|
||||||
|
// Sort each list by index to simulate real worker behavior
|
||||||
|
// (workers process chunks in ascending order via nextChunk.Add(1))
|
||||||
|
for _, list := range lists {
|
||||||
|
sort.Slice(list, func(i, j int) bool {
|
||||||
|
return list[i].item.Index() < list[j].item.Index()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
items := []Result{}
|
||||||
|
for _, list := range lists {
|
||||||
|
items = append(items, list...)
|
||||||
|
}
|
||||||
|
sort.Slice(items, func(i, j int) bool {
|
||||||
|
return items[i].item.Index() < items[j].item.Index()
|
||||||
|
})
|
||||||
cnt := len(items)
|
cnt := len(items)
|
||||||
|
|
||||||
// Not sorted: same order
|
// Not sorted: items in ascending index order
|
||||||
mg := NewMerger(nil, lists, false, false, revision{}, 0, 0)
|
mg := NewMerger(nil, lists, false, false, revision{}, 0, 0)
|
||||||
assert(t, cnt == mg.Length(), "Invalid Length")
|
assert(t, cnt == mg.Length(), "Invalid Length")
|
||||||
for i := range cnt {
|
for i := range cnt {
|
||||||
|
|||||||
+152
-38
@@ -4,11 +4,12 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"time"
|
||||||
|
|
||||||
"github.com/junegunn/fzf/src/algo"
|
"github.com/junegunn/fzf/src/algo"
|
||||||
"github.com/junegunn/fzf/src/tui"
|
"github.com/junegunn/fzf/src/tui"
|
||||||
@@ -65,7 +66,7 @@ Usage: fzf [options]
|
|||||||
--no-bold Do not use bold text
|
--no-bold Do not use bold text
|
||||||
|
|
||||||
DISPLAY MODE
|
DISPLAY MODE
|
||||||
--height=[~]HEIGHT[%] Display fzf window below the cursor with the given
|
--height=[~][-]HEIGHT[%] Display fzf window below the cursor with the given
|
||||||
height instead of using fullscreen.
|
height instead of using fullscreen.
|
||||||
A negative value is calculated as the terminal height
|
A negative value is calculated as the terminal height
|
||||||
minus the given value.
|
minus the given value.
|
||||||
@@ -74,16 +75,17 @@ Usage: fzf [options]
|
|||||||
--min-height=HEIGHT[+] Minimum height when --height is given as a percentage.
|
--min-height=HEIGHT[+] Minimum height when --height is given as a percentage.
|
||||||
Add '+' to automatically increase the value
|
Add '+' to automatically increase the value
|
||||||
according to the other layout options (default: 10+).
|
according to the other layout options (default: 10+).
|
||||||
--tmux[=OPTS] Start fzf in a tmux popup (requires tmux 3.3+)
|
--popup[=OPTS] Start fzf in a floating pane (requires tmux 3.3+ or Zellij 0.44+)
|
||||||
[center|top|bottom|left|right][,SIZE[%]][,SIZE[%]]
|
[center|top|bottom|left|right][,SIZE[%]][,SIZE[%]]
|
||||||
[,border-native] (default: center,50%)
|
[,border-native] (default: center,50%)
|
||||||
|
--tmux[=OPTS] Alias for --popup
|
||||||
|
|
||||||
LAYOUT
|
LAYOUT
|
||||||
--layout=LAYOUT Choose layout: [default|reverse|reverse-list]
|
--layout=LAYOUT Choose layout: [default|reverse|reverse-list]
|
||||||
--margin=MARGIN Screen margin (TRBL | TB,RL | T,RL,B | T,R,B,L)
|
--margin=MARGIN Screen margin (TRBL | TB,RL | T,RL,B | T,R,B,L)
|
||||||
--padding=PADDING Padding inside border (TRBL | TB,RL | T,RL,B | T,R,B,L)
|
--padding=PADDING Padding inside border (TRBL | TB,RL | T,RL,B | T,R,B,L)
|
||||||
--border[=STYLE] Draw border around the finder
|
--border[=STYLE] Draw border around the finder
|
||||||
[rounded|sharp|bold|block|thinblock|double|horizontal|vertical|
|
[rounded|sharp|bold|block|thinblock|double|dashed|horizontal|vertical|
|
||||||
top|bottom|left|right|line|none] (default: rounded)
|
top|bottom|left|right|line|none] (default: rounded)
|
||||||
--border-label=LABEL Label to print on the border
|
--border-label=LABEL Label to print on the border
|
||||||
--border-label-pos=COL Position of the border label
|
--border-label-pos=COL Position of the border label
|
||||||
@@ -100,6 +102,7 @@ Usage: fzf [options]
|
|||||||
--no-multi-line Disable multi-line display of items when using --read0
|
--no-multi-line Disable multi-line display of items when using --read0
|
||||||
--raw Enable raw mode (show non-matching items)
|
--raw Enable raw mode (show non-matching items)
|
||||||
--track Track the current selection when the result is updated
|
--track Track the current selection when the result is updated
|
||||||
|
--id-nth=N[,..] Define item identity fields for cross-reload operations
|
||||||
--tac Reverse the order of the input
|
--tac Reverse the order of the input
|
||||||
--gap[=N] Render empty lines between each item
|
--gap[=N] Render empty lines between each item
|
||||||
--gap-line[=STR] Draw horizontal line on each gap using the string
|
--gap-line[=STR] Draw horizontal line on each gap using the string
|
||||||
@@ -125,7 +128,7 @@ Usage: fzf [options]
|
|||||||
(each for list section and preview window)
|
(each for list section and preview window)
|
||||||
--no-scrollbar Hide scrollbar
|
--no-scrollbar Hide scrollbar
|
||||||
--list-border[=STYLE] Draw border around the list section
|
--list-border[=STYLE] Draw border around the list section
|
||||||
[rounded|sharp|bold|block|thinblock|double|horizontal|vertical|
|
[rounded|sharp|bold|block|thinblock|double|dashed|horizontal|vertical|
|
||||||
top|bottom|left|right|none] (default: rounded)
|
top|bottom|left|right|none] (default: rounded)
|
||||||
--list-label=LABEL Label to print on the list border
|
--list-label=LABEL Label to print on the list border
|
||||||
--list-label-pos=COL Position of the list label
|
--list-label-pos=COL Position of the list label
|
||||||
@@ -145,7 +148,7 @@ Usage: fzf [options]
|
|||||||
--ghost=TEXT Ghost text to display when the input is empty
|
--ghost=TEXT Ghost text to display when the input is empty
|
||||||
--filepath-word Make word-wise movements respect path separators
|
--filepath-word Make word-wise movements respect path separators
|
||||||
--input-border[=STYLE] Draw border around the input section
|
--input-border[=STYLE] Draw border around the input section
|
||||||
[rounded|sharp|bold|block|thinblock|double|horizontal|vertical|
|
[rounded|sharp|bold|block|thinblock|double|dashed|horizontal|vertical|
|
||||||
top|bottom|left|right|line|none] (default: rounded)
|
top|bottom|left|right|line|none] (default: rounded)
|
||||||
--input-label=LABEL Label to print on the input border
|
--input-label=LABEL Label to print on the input border
|
||||||
--input-label-pos=COL Position of the input label
|
--input-label-pos=COL Position of the input label
|
||||||
@@ -156,13 +159,13 @@ Usage: fzf [options]
|
|||||||
PREVIEW WINDOW
|
PREVIEW WINDOW
|
||||||
--preview=COMMAND Command to preview highlighted line ({})
|
--preview=COMMAND Command to preview highlighted line ({})
|
||||||
--preview-window=OPT Preview window layout (default: right:50%)
|
--preview-window=OPT Preview window layout (default: right:50%)
|
||||||
[up|down|left|right][,SIZE[%]]
|
[up|down|left|right|next][,SIZE[%]]
|
||||||
[,[no]wrap[-word]][,[no]cycle][,[no]follow][,[no]info]
|
[,[no]wrap[-word]][,[no]cycle][,[no]follow][,[no]info]
|
||||||
[,[no]hidden][,border-STYLE]
|
[,[no]hidden][,border-STYLE]
|
||||||
[,+SCROLL[OFFSETS][/DENOM]][,~HEADER_LINES]
|
[,+SCROLL[OFFSETS][/DENOM]][,~HEADER_LINES]
|
||||||
[,default][,<SIZE_THRESHOLD(ALTERNATIVE_LAYOUT)]
|
[,default][,<SIZE_THRESHOLD(ALTERNATIVE_LAYOUT)]
|
||||||
--preview-border[=STYLE] Short for --preview-window=border-STYLE
|
--preview-border[=STYLE] Short for --preview-window=border-STYLE
|
||||||
[rounded|sharp|bold|block|thinblock|double|horizontal|vertical|
|
[rounded|sharp|bold|block|thinblock|double|dashed|horizontal|vertical|
|
||||||
top|bottom|left|right|line|none] (default: rounded)
|
top|bottom|left|right|line|none] (default: rounded)
|
||||||
--preview-label=LABEL
|
--preview-label=LABEL
|
||||||
--preview-label-pos=N Same as --border-label and --border-label-pos,
|
--preview-label-pos=N Same as --border-label and --border-label-pos,
|
||||||
@@ -174,11 +177,12 @@ Usage: fzf [options]
|
|||||||
--header-lines=N The first N lines of the input are treated as header
|
--header-lines=N The first N lines of the input are treated as header
|
||||||
--header-first Print header before the prompt line
|
--header-first Print header before the prompt line
|
||||||
--header-border[=STYLE] Draw border around the header section
|
--header-border[=STYLE] Draw border around the header section
|
||||||
[rounded|sharp|bold|block|thinblock|double|horizontal|vertical|
|
[rounded|sharp|bold|block|thinblock|double|dashed|horizontal|vertical|
|
||||||
top|bottom|left|right|line|none] (default: rounded)
|
top|bottom|left|right|line|inline|none] (default: rounded)
|
||||||
--header-lines-border[=STYLE]
|
--header-lines-border[=STYLE]
|
||||||
Display header from --header-lines with a separate border.
|
Display header from --header-lines with a separate border.
|
||||||
Pass 'none' to still separate it but without a border.
|
Pass 'none' to still separate it but without a border.
|
||||||
|
Pass 'inline' to embed it inside the list frame.
|
||||||
--header-label=LABEL Label to print on the header border
|
--header-label=LABEL Label to print on the header border
|
||||||
--header-label-pos=COL Position of the header label
|
--header-label-pos=COL Position of the header label
|
||||||
[POSITIVE_INTEGER: columns from left|
|
[POSITIVE_INTEGER: columns from left|
|
||||||
@@ -188,8 +192,8 @@ Usage: fzf [options]
|
|||||||
FOOTER
|
FOOTER
|
||||||
--footer=STR String to print as footer
|
--footer=STR String to print as footer
|
||||||
--footer-border[=STYLE] Draw border around the footer section
|
--footer-border[=STYLE] Draw border around the footer section
|
||||||
[rounded|sharp|bold|block|thinblock|double|horizontal|vertical|
|
[rounded|sharp|bold|block|thinblock|double|dashed|horizontal|vertical|
|
||||||
top|bottom|left|right|line|none] (default: line)
|
top|bottom|left|right|line|inline|none] (default: line)
|
||||||
--footer-label=LABEL Label to print on the footer border
|
--footer-label=LABEL Label to print on the footer border
|
||||||
--footer-label-pos=COL Position of the footer label
|
--footer-label-pos=COL Position of the footer label
|
||||||
[POSITIVE_INTEGER: columns from left|
|
[POSITIVE_INTEGER: columns from left|
|
||||||
@@ -228,6 +232,7 @@ Usage: fzf [options]
|
|||||||
--bash Print script to set up Bash shell integration
|
--bash Print script to set up Bash shell integration
|
||||||
--zsh Print script to set up Zsh shell integration
|
--zsh Print script to set up Zsh shell integration
|
||||||
--fish Print script to set up Fish shell integration
|
--fish Print script to set up Fish shell integration
|
||||||
|
--nushell Print script to set up Nushell integration
|
||||||
|
|
||||||
HELP
|
HELP
|
||||||
--version Display version information and exit
|
--version Display version information and exit
|
||||||
@@ -327,6 +332,7 @@ const (
|
|||||||
posLeft
|
posLeft
|
||||||
posRight
|
posRight
|
||||||
posCenter
|
posCenter
|
||||||
|
posNext // adjacent to the input section, on the list side
|
||||||
)
|
)
|
||||||
|
|
||||||
type tmuxOptions struct {
|
type tmuxOptions struct {
|
||||||
@@ -386,7 +392,7 @@ func (o *previewOpts) Toggle() {
|
|||||||
o.hidden = !o.hidden
|
o.hidden = !o.hidden
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *previewOpts) Border() tui.BorderShape {
|
func (o *previewOpts) Border(layout layoutType) tui.BorderShape {
|
||||||
shape := o.border
|
shape := o.border
|
||||||
if shape == tui.BorderLine {
|
if shape == tui.BorderLine {
|
||||||
switch o.position {
|
switch o.position {
|
||||||
@@ -398,6 +404,12 @@ func (o *previewOpts) Border() tui.BorderShape {
|
|||||||
shape = tui.BorderRight
|
shape = tui.BorderRight
|
||||||
case posRight:
|
case posRight:
|
||||||
shape = tui.BorderLeft
|
shape = tui.BorderLeft
|
||||||
|
case posNext:
|
||||||
|
if layout == layoutReverse {
|
||||||
|
shape = tui.BorderBottom
|
||||||
|
} else {
|
||||||
|
shape = tui.BorderTop
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return shape
|
return shape
|
||||||
@@ -415,7 +427,7 @@ func parseTmuxOptions(arg string, index int) (*tmuxOptions, error) {
|
|||||||
var err error
|
var err error
|
||||||
opts := defaultTmuxOptions(index)
|
opts := defaultTmuxOptions(index)
|
||||||
tokens := splitRegexp.Split(arg, -1)
|
tokens := splitRegexp.Split(arg, -1)
|
||||||
errorToReturn := errors.New("invalid tmux option: " + arg + " (expected: [center|top|bottom|left|right][,SIZE[%]][,SIZE[%][,border-native]])")
|
errorToReturn := errors.New("invalid popup option: " + arg + " (expected: [center|top|bottom|left|right][,SIZE[%]][,SIZE[%]][,border-native])")
|
||||||
if len(tokens) == 0 || len(tokens) > 4 {
|
if len(tokens) == 0 || len(tokens) > 4 {
|
||||||
return nil, errorToReturn
|
return nil, errorToReturn
|
||||||
}
|
}
|
||||||
@@ -507,7 +519,7 @@ func parseLabelPosition(opts *labelOpts, arg string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a previewOpts) aboveOrBelow() bool {
|
func (a previewOpts) aboveOrBelow() bool {
|
||||||
return a.size.size > 0 && (a.position == posUp || a.position == posDown)
|
return a.size.size > 0 && (a.position == posUp || a.position == posDown || a.position == posNext)
|
||||||
}
|
}
|
||||||
|
|
||||||
type previewOptsCompare int
|
type previewOptsCompare int
|
||||||
@@ -574,6 +586,7 @@ type Options struct {
|
|||||||
Bash bool
|
Bash bool
|
||||||
Zsh bool
|
Zsh bool
|
||||||
Fish bool
|
Fish bool
|
||||||
|
Nushell bool
|
||||||
Man bool
|
Man bool
|
||||||
Fuzzy bool
|
Fuzzy bool
|
||||||
FuzzyAlgo algo.Algo
|
FuzzyAlgo algo.Algo
|
||||||
@@ -587,11 +600,13 @@ type Options struct {
|
|||||||
FreezeLeft int
|
FreezeLeft int
|
||||||
FreezeRight int
|
FreezeRight int
|
||||||
WithNth func(Delimiter) func([]Token, int32) string
|
WithNth func(Delimiter) func([]Token, int32) string
|
||||||
|
WithNthExpr string
|
||||||
AcceptNth func(Delimiter) func([]Token, int32) string
|
AcceptNth func(Delimiter) func([]Token, int32) string
|
||||||
Delimiter Delimiter
|
Delimiter Delimiter
|
||||||
Sort int
|
Sort int
|
||||||
Raw bool
|
Raw bool
|
||||||
Track trackOption
|
Track trackOption
|
||||||
|
IdNth []Range
|
||||||
Tac bool
|
Tac bool
|
||||||
Tail int
|
Tail int
|
||||||
Criteria []criterion
|
Criteria []criterion
|
||||||
@@ -677,6 +692,8 @@ type Options struct {
|
|||||||
WalkerSkip []string
|
WalkerSkip []string
|
||||||
Version bool
|
Version bool
|
||||||
Help bool
|
Help bool
|
||||||
|
Threads int
|
||||||
|
Bench time.Duration
|
||||||
CPUProfile string
|
CPUProfile string
|
||||||
MEMProfile string
|
MEMProfile string
|
||||||
BlockProfile string
|
BlockProfile string
|
||||||
@@ -717,6 +734,7 @@ func defaultOptions() *Options {
|
|||||||
Bash: false,
|
Bash: false,
|
||||||
Zsh: false,
|
Zsh: false,
|
||||||
Fish: false,
|
Fish: false,
|
||||||
|
Nushell: false,
|
||||||
Man: false,
|
Man: false,
|
||||||
Fuzzy: true,
|
Fuzzy: true,
|
||||||
FuzzyAlgo: algo.FuzzyMatchV2,
|
FuzzyAlgo: algo.FuzzyMatchV2,
|
||||||
@@ -863,7 +881,7 @@ func nthTransformer(str string) (func(Delimiter) func([]Token, int32) string, er
|
|||||||
nth []Range
|
nth []Range
|
||||||
}
|
}
|
||||||
|
|
||||||
parts := make([]NthParts, len(indexes))
|
parts := make([]NthParts, 0, len(indexes))
|
||||||
idx := 0
|
idx := 0
|
||||||
for _, index := range indexes {
|
for _, index := range indexes {
|
||||||
if idx < index[0] {
|
if idx < index[0] {
|
||||||
@@ -946,6 +964,8 @@ func parseBorder(str string, optional bool) (tui.BorderShape, error) {
|
|||||||
switch str {
|
switch str {
|
||||||
case "line":
|
case "line":
|
||||||
return tui.BorderLine, nil
|
return tui.BorderLine, nil
|
||||||
|
case "inline":
|
||||||
|
return tui.BorderInline, nil
|
||||||
case "rounded":
|
case "rounded":
|
||||||
return tui.BorderRounded, nil
|
return tui.BorderRounded, nil
|
||||||
case "sharp":
|
case "sharp":
|
||||||
@@ -958,6 +978,8 @@ func parseBorder(str string, optional bool) (tui.BorderShape, error) {
|
|||||||
return tui.BorderThinBlock, nil
|
return tui.BorderThinBlock, nil
|
||||||
case "double":
|
case "double":
|
||||||
return tui.BorderDouble, nil
|
return tui.BorderDouble, nil
|
||||||
|
case "dashed":
|
||||||
|
return tui.BorderDashed, nil
|
||||||
case "horizontal":
|
case "horizontal":
|
||||||
return tui.BorderHorizontal, nil
|
return tui.BorderHorizontal, nil
|
||||||
case "vertical":
|
case "vertical":
|
||||||
@@ -976,7 +998,7 @@ func parseBorder(str string, optional bool) (tui.BorderShape, error) {
|
|||||||
if optional && str == "" {
|
if optional && str == "" {
|
||||||
return defaultBorderShape, nil
|
return defaultBorderShape, nil
|
||||||
}
|
}
|
||||||
return tui.BorderNone, errors.New("invalid border style (expected: rounded|sharp|bold|block|thinblock|double|horizontal|vertical|top|bottom|left|right|none)")
|
return tui.BorderNone, errors.New("invalid border style (expected: rounded|sharp|bold|block|thinblock|double|dashed|horizontal|vertical|top|bottom|left|right|line|inline|none)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseKeyChords(str string, message string) (map[tui.Event]string, []tui.Event, error) {
|
func parseKeyChords(str string, message string) (map[tui.Event]string, []tui.Event, error) {
|
||||||
@@ -1041,6 +1063,8 @@ func parseKeyChords(str string, message string) (map[tui.Event]string, []tui.Eve
|
|||||||
add(tui.Focus)
|
add(tui.Focus)
|
||||||
case "result":
|
case "result":
|
||||||
add(tui.Result)
|
add(tui.Result)
|
||||||
|
case "result-final":
|
||||||
|
add(tui.ResultFinal)
|
||||||
case "resize":
|
case "resize":
|
||||||
add(tui.Resize)
|
add(tui.Resize)
|
||||||
case "one":
|
case "one":
|
||||||
@@ -1245,7 +1269,14 @@ func parseKeyChords(str string, message string) (map[tui.Event]string, []tui.Eve
|
|||||||
add(tui.F12)
|
add(tui.F12)
|
||||||
default:
|
default:
|
||||||
runes := []rune(key)
|
runes := []rune(key)
|
||||||
if len(key) == 10 && strings.HasPrefix(lkey, "ctrl-alt-") && isAlphabet(lkey[9]) {
|
if strings.HasPrefix(lkey, "every(") && strings.HasSuffix(lkey, ")") {
|
||||||
|
evt, err := parseEveryEvent(key[6 : len(key)-1])
|
||||||
|
if err != nil {
|
||||||
|
return nil, list, err
|
||||||
|
}
|
||||||
|
chords[evt] = key
|
||||||
|
list = append(list, evt)
|
||||||
|
} else if len(key) == 10 && strings.HasPrefix(lkey, "ctrl-alt-") && isAlphabet(lkey[9]) {
|
||||||
r := rune(lkey[9])
|
r := rune(lkey[9])
|
||||||
evt := tui.CtrlAltKey(r)
|
evt := tui.CtrlAltKey(r)
|
||||||
if r == 'h' && !util.IsWindows() {
|
if r == 'h' && !util.IsWindows() {
|
||||||
@@ -1287,6 +1318,21 @@ func parseKeyChords(str string, message string) (map[tui.Event]string, []tui.Eve
|
|||||||
return chords, list, nil
|
return chords, list, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseEveryEvent(arg string) (tui.Event, error) {
|
||||||
|
secs, err := strconv.ParseFloat(strings.TrimSpace(arg), 64)
|
||||||
|
if err != nil || math.IsNaN(secs) || math.IsInf(secs, 0) || secs <= 0 {
|
||||||
|
return tui.Event{}, errors.New("every() requires a positive number of seconds")
|
||||||
|
}
|
||||||
|
if secs < 0.01 {
|
||||||
|
secs = 0.01
|
||||||
|
}
|
||||||
|
ms := math.Round(secs * 1000)
|
||||||
|
if ms > math.MaxInt32 {
|
||||||
|
return tui.Event{}, errors.New("every() interval is too large")
|
||||||
|
}
|
||||||
|
return tui.Event{Type: tui.Every, Char: rune(int32(ms))}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func parseScheme(str string) (string, []criterion, error) {
|
func parseScheme(str string) (string, []criterion, error) {
|
||||||
str = strings.ToLower(str)
|
str = strings.ToLower(str)
|
||||||
switch str {
|
switch str {
|
||||||
@@ -1560,7 +1606,7 @@ func parseTheme(defaultTheme *tui.ColorTheme, str string) (*tui.ColorTheme, *tui
|
|||||||
case "info":
|
case "info":
|
||||||
mergeAttr(&theme.Info)
|
mergeAttr(&theme.Info)
|
||||||
case "pointer":
|
case "pointer":
|
||||||
mergeAttr(&theme.Cursor)
|
mergeAttr(&theme.Pointer)
|
||||||
case "marker":
|
case "marker":
|
||||||
mergeAttr(&theme.Marker)
|
mergeAttr(&theme.Marker)
|
||||||
case "header", "header-fg":
|
case "header", "header-fg":
|
||||||
@@ -1606,7 +1652,7 @@ func parseWalkerOpts(str string) (walkerOpts, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
executeRegexp *regexp.Regexp
|
argActionRegexp *regexp.Regexp
|
||||||
splitRegexp *regexp.Regexp
|
splitRegexp *regexp.Regexp
|
||||||
actionNameRegexp *regexp.Regexp
|
actionNameRegexp *regexp.Regexp
|
||||||
)
|
)
|
||||||
@@ -1625,8 +1671,8 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
executeRegexp = regexp.MustCompile(
|
argActionRegexp = regexp.MustCompile(
|
||||||
`(?si)[:+](become|execute(?:-multi|-silent)?|reload(?:-sync)?|preview|(?:change|bg-transform|transform)-(?:query|prompt|(?:border|list|preview|input|header|footer)-label|header|footer|search|nth|pointer|ghost)|bg-transform|transform|change-(?:preview-window|preview|multi)|(?:re|un|toggle-)bind|pos|put|print|search|trigger)`)
|
`(?si)[:+](become|execute(?:-multi|-silent)?|reload(?:-sync)?|preview|(?:change|bg-transform|transform)-(?:query|prompt|(?:border|list|preview|input|header|footer)-label|header-lines|header|footer|search|with-nth|nth|pointer|ghost)|bg-transform|transform|change-(?:preview-window|preview|multi)|(?:re|un|toggle-)bind|pos|put|print|search|trigger)`)
|
||||||
splitRegexp = regexp.MustCompile("[,:]+")
|
splitRegexp = regexp.MustCompile("[,:]+")
|
||||||
actionNameRegexp = regexp.MustCompile("(?i)^[a-z-]+")
|
actionNameRegexp = regexp.MustCompile("(?i)^[a-z-]+")
|
||||||
}
|
}
|
||||||
@@ -1635,7 +1681,7 @@ func maskActionContents(action string) string {
|
|||||||
masked := ""
|
masked := ""
|
||||||
Loop:
|
Loop:
|
||||||
for len(action) > 0 {
|
for len(action) > 0 {
|
||||||
loc := executeRegexp.FindStringIndex(action)
|
loc := argActionRegexp.FindStringIndex(action)
|
||||||
if loc == nil {
|
if loc == nil {
|
||||||
masked += action
|
masked += action
|
||||||
break
|
break
|
||||||
@@ -1689,10 +1735,10 @@ Loop:
|
|||||||
return masked
|
return masked
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseSingleActionList(str string) ([]*action, error) {
|
func parseSingleActionList(str string, putAllowed bool) ([]*action, error) {
|
||||||
// We prepend a colon to satisfy executeRegexp and remove it later
|
// We prepend a colon to satisfy argActionRegexp and remove it later
|
||||||
masked := maskActionContents(":" + str)[1:]
|
masked := maskActionContents(":" + str)[1:]
|
||||||
return parseActionList(masked, str, []*action{}, false)
|
return parseActionList(masked, str, []*action{}, putAllowed)
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseActionList(masked string, original string, prevActions []*action, putAllowed bool) ([]*action, error) {
|
func parseActionList(masked string, original string, prevActions []*action, putAllowed bool) ([]*action, error) {
|
||||||
@@ -1912,6 +1958,8 @@ func parseActionList(masked string, original string, prevActions []*action, putA
|
|||||||
} else {
|
} else {
|
||||||
return nil, errors.New("unable to put non-printable character")
|
return nil, errors.New("unable to put non-printable character")
|
||||||
}
|
}
|
||||||
|
case "wait":
|
||||||
|
appendAction(actWait)
|
||||||
case "bell":
|
case "bell":
|
||||||
appendAction(actBell)
|
appendAction(actBell)
|
||||||
case "exclude":
|
case "exclude":
|
||||||
@@ -1998,8 +2046,7 @@ func parseKeymap(keymap map[tui.Event][]*action, str string) error {
|
|||||||
}
|
}
|
||||||
key = firstKey(keys)
|
key = firstKey(keys)
|
||||||
}
|
}
|
||||||
putAllowed := key.Type == tui.Rune && unicode.IsGraphic(key.Char)
|
keymap[key], err = parseActionList(pair[1], origPairStr[len(pair[0])+1:], keymap[key], key.Printable())
|
||||||
keymap[key], err = parseActionList(pair[1], origPairStr[len(pair[0])+1:], keymap[key], putAllowed)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -2037,6 +2084,8 @@ func isExecuteAction(str string) actionType {
|
|||||||
return actPreview
|
return actPreview
|
||||||
case "change-header":
|
case "change-header":
|
||||||
return actChangeHeader
|
return actChangeHeader
|
||||||
|
case "change-header-lines":
|
||||||
|
return actChangeHeaderLines
|
||||||
case "change-footer":
|
case "change-footer":
|
||||||
return actChangeFooter
|
return actChangeFooter
|
||||||
case "change-list-label":
|
case "change-list-label":
|
||||||
@@ -2067,6 +2116,8 @@ func isExecuteAction(str string) actionType {
|
|||||||
return actChangeMulti
|
return actChangeMulti
|
||||||
case "change-nth":
|
case "change-nth":
|
||||||
return actChangeNth
|
return actChangeNth
|
||||||
|
case "change-with-nth":
|
||||||
|
return actChangeWithNth
|
||||||
case "pos":
|
case "pos":
|
||||||
return actPosition
|
return actPosition
|
||||||
case "execute":
|
case "execute":
|
||||||
@@ -2097,10 +2148,14 @@ func isExecuteAction(str string) actionType {
|
|||||||
return actTransformFooter
|
return actTransformFooter
|
||||||
case "transform-header":
|
case "transform-header":
|
||||||
return actTransformHeader
|
return actTransformHeader
|
||||||
|
case "transform-header-lines":
|
||||||
|
return actTransformHeaderLines
|
||||||
case "transform-ghost":
|
case "transform-ghost":
|
||||||
return actTransformGhost
|
return actTransformGhost
|
||||||
case "transform-nth":
|
case "transform-nth":
|
||||||
return actTransformNth
|
return actTransformNth
|
||||||
|
case "transform-with-nth":
|
||||||
|
return actTransformWithNth
|
||||||
case "transform-pointer":
|
case "transform-pointer":
|
||||||
return actTransformPointer
|
return actTransformPointer
|
||||||
case "transform-prompt":
|
case "transform-prompt":
|
||||||
@@ -2127,10 +2182,14 @@ func isExecuteAction(str string) actionType {
|
|||||||
return actBgTransformFooter
|
return actBgTransformFooter
|
||||||
case "bg-transform-header":
|
case "bg-transform-header":
|
||||||
return actBgTransformHeader
|
return actBgTransformHeader
|
||||||
|
case "bg-transform-header-lines":
|
||||||
|
return actBgTransformHeaderLines
|
||||||
case "bg-transform-ghost":
|
case "bg-transform-ghost":
|
||||||
return actBgTransformGhost
|
return actBgTransformGhost
|
||||||
case "bg-transform-nth":
|
case "bg-transform-nth":
|
||||||
return actBgTransformNth
|
return actBgTransformNth
|
||||||
|
case "bg-transform-with-nth":
|
||||||
|
return actBgTransformWithNth
|
||||||
case "bg-transform-pointer":
|
case "bg-transform-pointer":
|
||||||
return actBgTransformPointer
|
return actBgTransformPointer
|
||||||
case "bg-transform-prompt":
|
case "bg-transform-prompt":
|
||||||
@@ -2203,9 +2262,6 @@ func parseHeight(str string, index int) (heightSpec, error) {
|
|||||||
str = str[1:]
|
str = str[1:]
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(str, "-") {
|
if strings.HasPrefix(str, "-") {
|
||||||
if heightSpec.auto {
|
|
||||||
return heightSpec, errors.New("negative(-) height is not compatible with adaptive(~) height")
|
|
||||||
}
|
|
||||||
heightSpec.inverse = true
|
heightSpec.inverse = true
|
||||||
str = str[1:]
|
str = str[1:]
|
||||||
}
|
}
|
||||||
@@ -2308,6 +2364,8 @@ func parsePreviewWindowImpl(opts *previewOpts, input string) error {
|
|||||||
opts.position = posLeft
|
opts.position = posLeft
|
||||||
case "right":
|
case "right":
|
||||||
opts.position = posRight
|
opts.position = posRight
|
||||||
|
case "next":
|
||||||
|
opts.position = posNext
|
||||||
case "rounded", "border", "border-rounded":
|
case "rounded", "border", "border-rounded":
|
||||||
opts.border = tui.BorderRounded
|
opts.border = tui.BorderRounded
|
||||||
case "border-line":
|
case "border-line":
|
||||||
@@ -2322,6 +2380,8 @@ func parsePreviewWindowImpl(opts *previewOpts, input string) error {
|
|||||||
opts.border = tui.BorderThinBlock
|
opts.border = tui.BorderThinBlock
|
||||||
case "border-double":
|
case "border-double":
|
||||||
opts.border = tui.BorderDouble
|
opts.border = tui.BorderDouble
|
||||||
|
case "border-dashed":
|
||||||
|
opts.border = tui.BorderDashed
|
||||||
case "noborder", "border-none":
|
case "noborder", "border-none":
|
||||||
opts.border = tui.BorderNone
|
opts.border = tui.BorderNone
|
||||||
case "border-horizontal":
|
case "border-horizontal":
|
||||||
@@ -2498,6 +2558,7 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
|
|||||||
opts.Bash = false
|
opts.Bash = false
|
||||||
opts.Zsh = false
|
opts.Zsh = false
|
||||||
opts.Fish = false
|
opts.Fish = false
|
||||||
|
opts.Nushell = false
|
||||||
opts.Help = false
|
opts.Help = false
|
||||||
opts.Version = false
|
opts.Version = false
|
||||||
opts.Man = false
|
opts.Man = false
|
||||||
@@ -2610,6 +2671,9 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
|
|||||||
case "--fish":
|
case "--fish":
|
||||||
clearExitingOpts()
|
clearExitingOpts()
|
||||||
opts.Fish = true
|
opts.Fish = true
|
||||||
|
case "--nushell":
|
||||||
|
clearExitingOpts()
|
||||||
|
opts.Nushell = true
|
||||||
case "-h", "--help":
|
case "-h", "--help":
|
||||||
clearExitingOpts()
|
clearExitingOpts()
|
||||||
opts.Help = true
|
opts.Help = true
|
||||||
@@ -2618,7 +2682,7 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
|
|||||||
opts.Version = true
|
opts.Version = true
|
||||||
case "--no-winpty":
|
case "--no-winpty":
|
||||||
opts.NoWinpty = true
|
opts.NoWinpty = true
|
||||||
case "--tmux":
|
case "--tmux", "--popup":
|
||||||
given, str := optionalNextString()
|
given, str := optionalNextString()
|
||||||
if given {
|
if given {
|
||||||
if opts.Tmux, err = parseTmuxOptions(str, index); err != nil {
|
if opts.Tmux, err = parseTmuxOptions(str, index); err != nil {
|
||||||
@@ -2627,7 +2691,7 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
|
|||||||
} else {
|
} else {
|
||||||
opts.Tmux = defaultTmuxOptions(index)
|
opts.Tmux = defaultTmuxOptions(index)
|
||||||
}
|
}
|
||||||
case "--no-tmux":
|
case "--no-tmux", "--no-popup":
|
||||||
opts.Tmux = nil
|
opts.Tmux = nil
|
||||||
case "--tty-default":
|
case "--tty-default":
|
||||||
if opts.TtyDefault, err = nextString("tty device name required"); err != nil {
|
if opts.TtyDefault, err = nextString("tty device name required"); err != nil {
|
||||||
@@ -2772,6 +2836,7 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
|
|||||||
if opts.WithNth, err = nthTransformer(str); err != nil {
|
if opts.WithNth, err = nthTransformer(str); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
opts.WithNthExpr = str
|
||||||
case "--accept-nth":
|
case "--accept-nth":
|
||||||
str, err := nextString("nth expression required")
|
str, err := nextString("nth expression required")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -2794,6 +2859,16 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
|
|||||||
opts.Track = trackEnabled
|
opts.Track = trackEnabled
|
||||||
case "--no-track":
|
case "--no-track":
|
||||||
opts.Track = trackDisabled
|
opts.Track = trackDisabled
|
||||||
|
case "--id-nth":
|
||||||
|
str, err := nextString("nth expression required")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if opts.IdNth, err = splitNth(str); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case "--no-id-nth":
|
||||||
|
opts.IdNth = nil
|
||||||
case "--tac":
|
case "--tac":
|
||||||
opts.Tac = true
|
opts.Tac = true
|
||||||
case "--no-tac":
|
case "--no-tac":
|
||||||
@@ -3101,7 +3176,7 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
|
|||||||
case "--no-preview":
|
case "--no-preview":
|
||||||
opts.Preview.command = ""
|
opts.Preview.command = ""
|
||||||
case "--preview-window":
|
case "--preview-window":
|
||||||
str, err := nextString("preview window layout required: [up|down|left|right][,SIZE[%]][,border-STYLE][,wrap][,cycle][,hidden][,+SCROLL[OFFSETS][/DENOM]][,~HEADER_LINES][,default]")
|
str, err := nextString("preview window layout required: [up|down|left|right|next][,SIZE[%]][,border-STYLE][,wrap][,cycle][,hidden][,+SCROLL[OFFSETS][/DENOM]][,~HEADER_LINES][,default]")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -3122,7 +3197,7 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
|
|||||||
}
|
}
|
||||||
opts.PreviewWrapSign = &str
|
opts.PreviewWrapSign = &str
|
||||||
case "--height":
|
case "--height":
|
||||||
str, err := nextString("height required: [~]HEIGHT[%]")
|
str, err := nextString("height required: [~][-]HEIGHT[%]")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -3367,6 +3442,23 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
opts.WalkerSkip = filterNonEmpty(strings.Split(str, ","))
|
opts.WalkerSkip = filterNonEmpty(strings.Split(str, ","))
|
||||||
|
case "--threads":
|
||||||
|
if opts.Threads, err = nextInt("number of threads required"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if opts.Threads < 0 {
|
||||||
|
return errors.New("--threads must be a positive integer")
|
||||||
|
}
|
||||||
|
case "--bench":
|
||||||
|
str, err := nextString("duration required (e.g. 3s, 500ms)")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dur, err := time.ParseDuration(str)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("invalid duration for --bench: " + str)
|
||||||
|
}
|
||||||
|
opts.Bench = dur
|
||||||
case "--profile-cpu":
|
case "--profile-cpu":
|
||||||
if opts.CPUProfile, err = nextString("file path required: cpu"); err != nil {
|
if opts.CPUProfile, err = nextString("file path required: cpu"); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -3472,7 +3564,9 @@ func applyPreset(opts *Options, preset string) error {
|
|||||||
opts.Preview.border = tui.BorderLine
|
opts.Preview.border = tui.BorderLine
|
||||||
opts.Preview.info = false
|
opts.Preview.info = false
|
||||||
opts.InfoStyle = infoDefault
|
opts.InfoStyle = infoDefault
|
||||||
opts.Theme.Gutter = tui.ColorAttr{Color: -1, Attr: 0}
|
opts.Theme.Gutter = tui.NewColorAttr()
|
||||||
|
space := " "
|
||||||
|
opts.Gutter = &space
|
||||||
empty := ""
|
empty := ""
|
||||||
opts.Separator = &empty
|
opts.Separator = &empty
|
||||||
opts.Scrollbar = &empty
|
opts.Scrollbar = &empty
|
||||||
@@ -3547,7 +3641,7 @@ func validateOptions(opts *Options) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.Height.auto {
|
if opts.Height.auto && (opts.Tmux == nil || opts.Tmux.index < opts.Height.index) {
|
||||||
for _, s := range []sizeSpec{opts.Margin[0], opts.Margin[2]} {
|
for _, s := range []sizeSpec{opts.Margin[0], opts.Margin[2]} {
|
||||||
if s.percent {
|
if s.percent {
|
||||||
return errors.New("adaptive height is not compatible with top/bottom percent margin")
|
return errors.New("adaptive height is not compatible with top/bottom percent margin")
|
||||||
@@ -3564,6 +3658,19 @@ func validateOptions(opts *Options) error {
|
|||||||
return errors.New("only ANSI attributes are allowed for 'nth' (regular, bold, underline, reverse, dim, italic, strikethrough)")
|
return errors.New("only ANSI attributes are allowed for 'nth' (regular, bold, underline, reverse, dim, italic, strikethrough)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if opts.BorderShape == tui.BorderInline ||
|
||||||
|
opts.ListBorderShape == tui.BorderInline ||
|
||||||
|
opts.InputBorderShape == tui.BorderInline ||
|
||||||
|
opts.Preview.border == tui.BorderInline {
|
||||||
|
return errors.New("inline border is only supported for --header-border, --header-lines-border, and --footer-border")
|
||||||
|
}
|
||||||
|
if opts.HeaderBorderShape == tui.BorderInline &&
|
||||||
|
opts.HeaderLinesShape != tui.BorderInline &&
|
||||||
|
opts.HeaderLinesShape != tui.BorderUndefined &&
|
||||||
|
opts.HeaderLinesShape != tui.BorderNone {
|
||||||
|
return errors.New("--header-border=inline requires --header-lines-border to be inline or unset")
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3581,11 +3688,18 @@ func (opts *Options) useTmux() bool {
|
|||||||
return opts.Tmux != nil && len(os.Getenv("TMUX")) > 0 && opts.Tmux.index >= opts.Height.index
|
return opts.Tmux != nil && len(os.Getenv("TMUX")) > 0 && opts.Tmux.index >= opts.Height.index
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (opts *Options) useZellij() bool {
|
||||||
|
return opts.Tmux != nil && len(os.Getenv("ZELLIJ")) > 0 && opts.Tmux.index >= opts.Height.index
|
||||||
|
}
|
||||||
|
|
||||||
func (opts *Options) noSeparatorLine() bool {
|
func (opts *Options) noSeparatorLine() bool {
|
||||||
if opts.Inputless {
|
if opts.Inputless {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
sep := opts.Separator == nil && !opts.InputBorderShape.Visible() || opts.Separator != nil && len(*opts.Separator) > 0
|
// NOTE: This does not know that the default separator can be suppressed at
|
||||||
|
// runtime (see Terminal.separatorLength), so the minimum heights derived
|
||||||
|
// from it can be one line taller than strictly necessary.
|
||||||
|
sep := opts.Separator == nil && !inputBorderFacesList(opts.Layout, opts.InputBorderShape) || opts.Separator != nil && len(*opts.Separator) > 0
|
||||||
return noSeparatorLine(opts.InfoStyle, sep)
|
return noSeparatorLine(opts.InfoStyle, sep)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+35
-2
@@ -299,6 +299,39 @@ func TestBind(t *testing.T) {
|
|||||||
check(tui.F1.AsEvent(), "", actAbort)
|
check(tui.F1.AsEvent(), "", actAbort)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseEveryEvent(t *testing.T) {
|
||||||
|
pairs, _, err := parseKeyChords("every(2),every(0.5)", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(pairs) != 2 {
|
||||||
|
t.Errorf("expected 2 distinct every events, got %d", len(pairs))
|
||||||
|
}
|
||||||
|
if pairs[(tui.Event{Type: tui.Every, Char: 2000})] != "every(2)" {
|
||||||
|
t.Errorf("every(2) not registered")
|
||||||
|
}
|
||||||
|
if pairs[(tui.Event{Type: tui.Every, Char: 500})] != "every(0.5)" {
|
||||||
|
t.Errorf("every(0.5) not registered")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floor at 0.01s -> 10ms
|
||||||
|
pairs, _, err = parseKeyChords("every(0.001)", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if pairs[(tui.Event{Type: tui.Every, Char: 10})] != "every(0.001)" {
|
||||||
|
t.Errorf("every(0.001) should floor to 10ms")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject zero, negatives, and overflow (>= 2^31 ms = ~24.85 days)
|
||||||
|
for _, bad := range []string{"every(0)", "every(-1)", "every(abc)", "every()", "every(2147484)"} {
|
||||||
|
if _, _, err := parseKeyChords(bad, ""); err == nil {
|
||||||
|
t.Errorf("%s should be rejected", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
func TestColorSpec(t *testing.T) {
|
func TestColorSpec(t *testing.T) {
|
||||||
var base *tui.ColorTheme
|
var base *tui.ColorTheme
|
||||||
theme := tui.Dark256
|
theme := tui.Dark256
|
||||||
@@ -539,7 +572,7 @@ func TestValidateSign(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestParseSingleActionList(t *testing.T) {
|
func TestParseSingleActionList(t *testing.T) {
|
||||||
actions, _ := parseSingleActionList("Execute@foo+bar,baz@+up+up+reload:down+down")
|
actions, _ := parseSingleActionList("Execute@foo+bar,baz@+up+up+reload:down+down", false)
|
||||||
if len(actions) != 4 {
|
if len(actions) != 4 {
|
||||||
t.Errorf("Invalid number of actions parsed:%d", len(actions))
|
t.Errorf("Invalid number of actions parsed:%d", len(actions))
|
||||||
}
|
}
|
||||||
@@ -555,7 +588,7 @@ func TestParseSingleActionList(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestParseSingleActionListError(t *testing.T) {
|
func TestParseSingleActionListError(t *testing.T) {
|
||||||
_, err := parseSingleActionList("change-query(foobar)baz")
|
_, err := parseSingleActionList("change-query(foobar)baz", false)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Errorf("Failed to detect error")
|
t.Errorf("Failed to detect error")
|
||||||
}
|
}
|
||||||
|
|||||||
+89
-48
@@ -61,9 +61,12 @@ type Pattern struct {
|
|||||||
delimiter Delimiter
|
delimiter Delimiter
|
||||||
nth []Range
|
nth []Range
|
||||||
revision revision
|
revision revision
|
||||||
procFun map[termType]algo.Algo
|
procFun [6]algo.Algo
|
||||||
cache *ChunkCache
|
cache *ChunkCache
|
||||||
denylist map[int32]struct{}
|
denylist map[int32]struct{}
|
||||||
|
startIndex int32
|
||||||
|
directAlgo algo.Algo
|
||||||
|
directTerm *term
|
||||||
}
|
}
|
||||||
|
|
||||||
var _splitRegex *regexp.Regexp
|
var _splitRegex *regexp.Regexp
|
||||||
@@ -74,7 +77,7 @@ func init() {
|
|||||||
|
|
||||||
// BuildPattern builds Pattern object from the given arguments
|
// BuildPattern builds Pattern object from the given arguments
|
||||||
func BuildPattern(cache *ChunkCache, patternCache map[string]*Pattern, fuzzy bool, fuzzyAlgo algo.Algo, extended bool, caseMode Case, normalize bool, forward bool,
|
func BuildPattern(cache *ChunkCache, patternCache map[string]*Pattern, fuzzy bool, fuzzyAlgo algo.Algo, extended bool, caseMode Case, normalize bool, forward bool,
|
||||||
withPos bool, cacheable bool, nth []Range, delimiter Delimiter, revision revision, runes []rune, denylist map[int32]struct{}) *Pattern {
|
withPos bool, cacheable bool, nth []Range, delimiter Delimiter, revision revision, runes []rune, denylist map[int32]struct{}, startIndex int32) *Pattern {
|
||||||
|
|
||||||
var asString string
|
var asString string
|
||||||
if extended {
|
if extended {
|
||||||
@@ -146,9 +149,11 @@ func BuildPattern(cache *ChunkCache, patternCache map[string]*Pattern, fuzzy boo
|
|||||||
delimiter: delimiter,
|
delimiter: delimiter,
|
||||||
cache: cache,
|
cache: cache,
|
||||||
denylist: denylist,
|
denylist: denylist,
|
||||||
procFun: make(map[termType]algo.Algo)}
|
startIndex: startIndex,
|
||||||
|
}
|
||||||
|
|
||||||
ptr.cacheKey = ptr.buildCacheKey()
|
ptr.cacheKey = ptr.buildCacheKey()
|
||||||
|
ptr.directAlgo, ptr.directTerm = ptr.buildDirectAlgo(fuzzyAlgo)
|
||||||
ptr.procFun[termFuzzy] = fuzzyAlgo
|
ptr.procFun[termFuzzy] = fuzzyAlgo
|
||||||
ptr.procFun[termEqual] = algo.EqualMatch
|
ptr.procFun[termEqual] = algo.EqualMatch
|
||||||
ptr.procFun[termExact] = algo.ExactMatchNaive
|
ptr.procFun[termExact] = algo.ExactMatchNaive
|
||||||
@@ -272,6 +277,22 @@ func (p *Pattern) buildCacheKey() string {
|
|||||||
return strings.Join(cacheableTerms, "\t")
|
return strings.Join(cacheableTerms, "\t")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildDirectAlgo returns the algo function and term for the direct fast path
|
||||||
|
// in matchChunk. Returns (nil, nil) if the pattern is not suitable.
|
||||||
|
// Requirements: extended mode, single term set with single non-inverse fuzzy term, no nth.
|
||||||
|
func (p *Pattern) buildDirectAlgo(fuzzyAlgo algo.Algo) (algo.Algo, *term) {
|
||||||
|
if !p.extended || len(p.nth) > 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if len(p.termSets) == 1 && len(p.termSets[0]) == 1 {
|
||||||
|
t := &p.termSets[0][0]
|
||||||
|
if !t.inv && t.typ == termFuzzy {
|
||||||
|
return fuzzyAlgo, t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
// CacheKey is used to build string to be used as the key of result cache
|
// CacheKey is used to build string to be used as the key of result cache
|
||||||
func (p *Pattern) CacheKey() string {
|
func (p *Pattern) CacheKey() string {
|
||||||
return p.cacheKey
|
return p.cacheKey
|
||||||
@@ -279,84 +300,104 @@ func (p *Pattern) CacheKey() string {
|
|||||||
|
|
||||||
// Match returns the list of matches Items in the given Chunk
|
// Match returns the list of matches Items in the given Chunk
|
||||||
func (p *Pattern) Match(chunk *Chunk, slab *util.Slab) []Result {
|
func (p *Pattern) Match(chunk *Chunk, slab *util.Slab) []Result {
|
||||||
// ChunkCache: Exact match
|
|
||||||
cacheKey := p.CacheKey()
|
cacheKey := p.CacheKey()
|
||||||
|
|
||||||
|
// Bitmap cache: exact match or prefix/suffix
|
||||||
|
var cachedBitmap *ChunkBitmap
|
||||||
if p.cacheable {
|
if p.cacheable {
|
||||||
if cached := p.cache.Lookup(chunk, cacheKey); cached != nil {
|
cachedBitmap = p.cache.Lookup(chunk, cacheKey)
|
||||||
return cached
|
|
||||||
}
|
}
|
||||||
|
if cachedBitmap == nil {
|
||||||
|
cachedBitmap = p.cache.Search(chunk, cacheKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefix/suffix cache
|
matches, bitmap := p.matchChunk(chunk, cachedBitmap, slab)
|
||||||
space := p.cache.Search(chunk, cacheKey)
|
|
||||||
|
|
||||||
matches := p.matchChunk(chunk, space, slab)
|
|
||||||
|
|
||||||
if p.cacheable {
|
if p.cacheable {
|
||||||
p.cache.Add(chunk, cacheKey, matches)
|
p.cache.Add(chunk, cacheKey, bitmap, len(matches))
|
||||||
}
|
}
|
||||||
return matches
|
return matches
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Pattern) matchChunk(chunk *Chunk, space []Result, slab *util.Slab) []Result {
|
func (p *Pattern) matchChunk(chunk *Chunk, cachedBitmap *ChunkBitmap, slab *util.Slab) ([]Result, ChunkBitmap) {
|
||||||
matches := []Result{}
|
matches := []Result{}
|
||||||
|
var bitmap ChunkBitmap
|
||||||
|
|
||||||
|
// Skip header items in chunks that contain them
|
||||||
|
startIdx := 0
|
||||||
|
if p.startIndex > 0 && chunk.count > 0 && chunk.items[0].Index() < p.startIndex {
|
||||||
|
startIdx = int(p.startIndex - chunk.items[0].Index())
|
||||||
|
if startIdx >= chunk.count {
|
||||||
|
return matches, bitmap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hasCachedBitmap := cachedBitmap != nil
|
||||||
|
|
||||||
|
// Fast path: single fuzzy term, no nth, no denylist.
|
||||||
|
// Calls the algo function directly, bypassing MatchItem/extendedMatch/iter
|
||||||
|
// and avoiding per-match []Offset heap allocation.
|
||||||
|
if p.directAlgo != nil && len(p.denylist) == 0 {
|
||||||
|
t := p.directTerm
|
||||||
|
for idx := startIdx; idx < chunk.count; idx++ {
|
||||||
|
if hasCachedBitmap && cachedBitmap[idx/64]&(uint64(1)<<(idx%64)) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res, _ := p.directAlgo(t.caseSensitive, t.normalize, p.forward,
|
||||||
|
&chunk.items[idx].text, t.text, p.withPos, slab)
|
||||||
|
if res.Start >= 0 {
|
||||||
|
bitmap[idx/64] |= uint64(1) << (idx % 64)
|
||||||
|
matches = append(matches, buildResultFromBounds(
|
||||||
|
&chunk.items[idx], res.Score,
|
||||||
|
int(res.Start), int(res.End), int(res.End), true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches, bitmap
|
||||||
|
}
|
||||||
|
|
||||||
if len(p.denylist) == 0 {
|
if len(p.denylist) == 0 {
|
||||||
// Huge code duplication for minimizing unnecessary map lookups
|
for idx := startIdx; idx < chunk.count; idx++ {
|
||||||
if space == nil {
|
if hasCachedBitmap && cachedBitmap[idx/64]&(uint64(1)<<(idx%64)) == 0 {
|
||||||
for idx := 0; idx < chunk.count; idx++ {
|
continue
|
||||||
if match, _, _ := p.MatchItem(&chunk.items[idx], p.withPos, slab); match != nil {
|
}
|
||||||
matches = append(matches, *match)
|
if match, _, _ := p.MatchItem(&chunk.items[idx], p.withPos, slab); match.item != nil {
|
||||||
|
bitmap[idx/64] |= uint64(1) << (idx % 64)
|
||||||
|
matches = append(matches, match)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
return matches, bitmap
|
||||||
for _, result := range space {
|
|
||||||
if match, _, _ := p.MatchItem(result.item, p.withPos, slab); match != nil {
|
|
||||||
matches = append(matches, *match)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return matches
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if space == nil {
|
for idx := startIdx; idx < chunk.count; idx++ {
|
||||||
for idx := 0; idx < chunk.count; idx++ {
|
if hasCachedBitmap && cachedBitmap[idx/64]&(uint64(1)<<(idx%64)) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if _, prs := p.denylist[chunk.items[idx].Index()]; prs {
|
if _, prs := p.denylist[chunk.items[idx].Index()]; prs {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if match, _, _ := p.MatchItem(&chunk.items[idx], p.withPos, slab); match != nil {
|
if match, _, _ := p.MatchItem(&chunk.items[idx], p.withPos, slab); match.item != nil {
|
||||||
matches = append(matches, *match)
|
bitmap[idx/64] |= uint64(1) << (idx % 64)
|
||||||
|
matches = append(matches, match)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
return matches, bitmap
|
||||||
for _, result := range space {
|
|
||||||
if _, prs := p.denylist[result.item.Index()]; prs {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if match, _, _ := p.MatchItem(result.item, p.withPos, slab); match != nil {
|
|
||||||
matches = append(matches, *match)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return matches
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MatchItem returns true if the Item is a match
|
// MatchItem returns the match result if the Item is a match.
|
||||||
func (p *Pattern) MatchItem(item *Item, withPos bool, slab *util.Slab) (*Result, []Offset, *[]int) {
|
// A zero-value Result (with item == nil) indicates no match.
|
||||||
|
func (p *Pattern) MatchItem(item *Item, withPos bool, slab *util.Slab) (Result, []Offset, *[]int) {
|
||||||
if p.extended {
|
if p.extended {
|
||||||
if offsets, bonus, pos := p.extendedMatch(item, withPos, slab); len(offsets) == len(p.termSets) {
|
if offsets, bonus, pos := p.extendedMatch(item, withPos, slab); len(offsets) == len(p.termSets) {
|
||||||
result := buildResult(item, offsets, bonus)
|
return buildResult(item, offsets, bonus), offsets, pos
|
||||||
return &result, offsets, pos
|
|
||||||
}
|
}
|
||||||
return nil, nil, nil
|
return Result{}, nil, nil
|
||||||
}
|
}
|
||||||
offset, bonus, pos := p.basicMatch(item, withPos, slab)
|
offset, bonus, pos := p.basicMatch(item, withPos, slab)
|
||||||
if sidx := offset[0]; sidx >= 0 {
|
if sidx := offset[0]; sidx >= 0 {
|
||||||
offsets := []Offset{offset}
|
offsets := []Offset{offset}
|
||||||
result := buildResult(item, offsets, bonus)
|
return buildResult(item, offsets, bonus), offsets, pos
|
||||||
return &result, offsets, pos
|
|
||||||
}
|
}
|
||||||
return nil, nil, nil
|
return Result{}, nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Pattern) basicMatch(item *Item, withPos bool, slab *util.Slab) (Offset, int, *[]int) {
|
func (p *Pattern) basicMatch(item *Item, withPos bool, slab *util.Slab) (Offset, int, *[]int) {
|
||||||
|
|||||||
+119
-2
@@ -2,6 +2,7 @@ package fzf
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/junegunn/fzf/src/algo"
|
"github.com/junegunn/fzf/src/algo"
|
||||||
@@ -68,7 +69,7 @@ func buildPattern(fuzzy bool, fuzzyAlgo algo.Algo, extended bool, caseMode Case,
|
|||||||
withPos bool, cacheable bool, nth []Range, delimiter Delimiter, runes []rune) *Pattern {
|
withPos bool, cacheable bool, nth []Range, delimiter Delimiter, runes []rune) *Pattern {
|
||||||
return BuildPattern(NewChunkCache(), make(map[string]*Pattern),
|
return BuildPattern(NewChunkCache(), make(map[string]*Pattern),
|
||||||
fuzzy, fuzzyAlgo, extended, caseMode, normalize, forward,
|
fuzzy, fuzzyAlgo, extended, caseMode, normalize, forward,
|
||||||
withPos, cacheable, nth, delimiter, revision{}, runes, nil)
|
withPos, cacheable, nth, delimiter, revision{}, runes, nil, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExact(t *testing.T) {
|
func TestExact(t *testing.T) {
|
||||||
@@ -137,7 +138,7 @@ func TestOrigTextAndTransformed(t *testing.T) {
|
|||||||
origText: &origBytes,
|
origText: &origBytes,
|
||||||
transformed: &transformed{pattern.revision, trans}}
|
transformed: &transformed{pattern.revision, trans}}
|
||||||
pattern.extended = extended
|
pattern.extended = extended
|
||||||
matches := pattern.matchChunk(&chunk, nil, slab) // No cache
|
matches, _ := pattern.matchChunk(&chunk, nil, slab) // No cache
|
||||||
if !(matches[0].item.text.ToString() == "junegunn" &&
|
if !(matches[0].item.text.ToString() == "junegunn" &&
|
||||||
string(*matches[0].item.origText) == "junegunn.choi" &&
|
string(*matches[0].item.origText) == "junegunn.choi" &&
|
||||||
reflect.DeepEqual((*matches[0].item.transformed).tokens, trans)) {
|
reflect.DeepEqual((*matches[0].item.transformed).tokens, trans)) {
|
||||||
@@ -199,3 +200,119 @@ func TestCacheable(t *testing.T) {
|
|||||||
test(false, "foo 'bar", "foo", false)
|
test(false, "foo 'bar", "foo", false)
|
||||||
test(false, "foo !bar", "foo", false)
|
test(false, "foo !bar", "foo", false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildChunks(numChunks int) []*Chunk {
|
||||||
|
chunks := make([]*Chunk, numChunks)
|
||||||
|
words := []string{
|
||||||
|
"src/main/java/com/example/service/UserService.java",
|
||||||
|
"src/test/java/com/example/service/UserServiceTest.java",
|
||||||
|
"docs/api/reference/endpoints.md",
|
||||||
|
"lib/internal/utils/string_helper.go",
|
||||||
|
"pkg/server/http/handler/auth.go",
|
||||||
|
"build/output/release/app.exe",
|
||||||
|
"config/production/database.yml",
|
||||||
|
"scripts/deploy/kubernetes/setup.sh",
|
||||||
|
"vendor/github.com/junegunn/fzf/src/core.go",
|
||||||
|
"node_modules/.cache/babel/transform.js",
|
||||||
|
}
|
||||||
|
for ci := range numChunks {
|
||||||
|
chunks[ci] = &Chunk{count: chunkSize}
|
||||||
|
for i := range chunkSize {
|
||||||
|
text := words[(ci*chunkSize+i)%len(words)]
|
||||||
|
chunks[ci].items[i] = Item{text: util.ToChars([]byte(text))}
|
||||||
|
chunks[ci].items[i].text.Index = int32(ci*chunkSize + i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPatternWith(cache *ChunkCache, runes []rune) *Pattern {
|
||||||
|
return BuildPattern(cache, make(map[string]*Pattern),
|
||||||
|
true, algo.FuzzyMatchV2, true, CaseSmart, false, true,
|
||||||
|
false, true, []Range{}, Delimiter{}, revision{}, runes, nil, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBitmapCacheBenefit(t *testing.T) {
|
||||||
|
numChunks := 100
|
||||||
|
chunks := buildChunks(numChunks)
|
||||||
|
queries := []string{"s", "se", "ser", "serv", "servi"}
|
||||||
|
|
||||||
|
// 1. Run all queries with shared cache (simulates incremental typing)
|
||||||
|
cache := NewChunkCache()
|
||||||
|
for _, q := range queries {
|
||||||
|
pat := buildPatternWith(cache, []rune(q))
|
||||||
|
for _, chunk := range chunks {
|
||||||
|
pat.Match(chunk, slab)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. GC and measure memory with cache populated
|
||||||
|
runtime.GC()
|
||||||
|
runtime.GC()
|
||||||
|
var memWith runtime.MemStats
|
||||||
|
runtime.ReadMemStats(&memWith)
|
||||||
|
|
||||||
|
// 3. Clear cache, GC, measure again
|
||||||
|
cache.Clear()
|
||||||
|
runtime.GC()
|
||||||
|
runtime.GC()
|
||||||
|
var memWithout runtime.MemStats
|
||||||
|
runtime.ReadMemStats(&memWithout)
|
||||||
|
|
||||||
|
cacheMem := int64(memWith.Alloc) - int64(memWithout.Alloc)
|
||||||
|
t.Logf("Chunks: %d, Queries: %d", numChunks, len(queries))
|
||||||
|
t.Logf("Cache memory: %d bytes (%.1f KB)", cacheMem, float64(cacheMem)/1024)
|
||||||
|
t.Logf("Per-chunk-per-query: %.0f bytes", float64(cacheMem)/float64(numChunks*len(queries)))
|
||||||
|
|
||||||
|
// 4. Verify correctness: cached vs uncached produce same results
|
||||||
|
cache2 := NewChunkCache()
|
||||||
|
for _, q := range queries {
|
||||||
|
pat := buildPatternWith(cache2, []rune(q))
|
||||||
|
for _, chunk := range chunks {
|
||||||
|
pat.Match(chunk, slab)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, q := range queries {
|
||||||
|
patCached := buildPatternWith(cache2, []rune(q))
|
||||||
|
patFresh := buildPatternWith(NewChunkCache(), []rune(q))
|
||||||
|
var countCached, countFresh int
|
||||||
|
for _, chunk := range chunks {
|
||||||
|
countCached += len(patCached.Match(chunk, slab))
|
||||||
|
countFresh += len(patFresh.Match(chunk, slab))
|
||||||
|
}
|
||||||
|
if countCached != countFresh {
|
||||||
|
t.Errorf("query=%q: cached=%d, fresh=%d", q, countCached, countFresh)
|
||||||
|
}
|
||||||
|
t.Logf("query=%q: matches=%d", q, countCached)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkWithCache(b *testing.B) {
|
||||||
|
numChunks := 100
|
||||||
|
chunks := buildChunks(numChunks)
|
||||||
|
queries := []string{"s", "se", "ser", "serv", "servi"}
|
||||||
|
|
||||||
|
b.Run("cached", func(b *testing.B) {
|
||||||
|
for range b.N {
|
||||||
|
cache := NewChunkCache()
|
||||||
|
for _, q := range queries {
|
||||||
|
pat := buildPatternWith(cache, []rune(q))
|
||||||
|
for _, chunk := range chunks {
|
||||||
|
pat.Match(chunk, slab)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Run("uncached", func(b *testing.B) {
|
||||||
|
for range b.N {
|
||||||
|
for _, q := range queries {
|
||||||
|
cache := NewChunkCache()
|
||||||
|
pat := buildPatternWith(cache, []rune(q))
|
||||||
|
for _, chunk := range chunks {
|
||||||
|
pat.Match(chunk, slab)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,5 +6,5 @@ import "golang.org/x/sys/unix"
|
|||||||
|
|
||||||
// Protect calls OS specific protections like pledge on OpenBSD
|
// Protect calls OS specific protections like pledge on OpenBSD
|
||||||
func Protect() {
|
func Protect() {
|
||||||
unix.PledgePromises("stdio dpath wpath rpath tty proc exec inet tmppath")
|
unix.PledgePromises("stdio cpath dpath wpath rpath inet fattr unix tty proc exec")
|
||||||
}
|
}
|
||||||
|
|||||||
+108
-3
@@ -11,6 +11,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/junegunn/fzf/src/tui"
|
"github.com/junegunn/fzf/src/tui"
|
||||||
@@ -19,10 +20,101 @@ import (
|
|||||||
|
|
||||||
const becomeSuffix = ".become"
|
const becomeSuffix = ".become"
|
||||||
|
|
||||||
|
// Withheld from the environment replay below, so an outer value cannot
|
||||||
|
// override what the floating pane set
|
||||||
|
const internalEnvPrefix = "__FZF_INTERNAL_"
|
||||||
|
|
||||||
func escapeSingleQuote(str string) string {
|
func escapeSingleQuote(str string) string {
|
||||||
return "'" + strings.ReplaceAll(str, "'", "'\\''") + "'"
|
return "'" + strings.ReplaceAll(str, "'", "'\\''") + "'"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read and remove, so preview and execute commands do not inherit it
|
||||||
|
func takeEnv(name string) string {
|
||||||
|
value := os.Getenv(name)
|
||||||
|
os.Unsetenv(name)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applies label updates off the event loop, which would block on the fork
|
||||||
|
type labelUpdater struct {
|
||||||
|
set func(string)
|
||||||
|
mu sync.Mutex
|
||||||
|
pending *string
|
||||||
|
running bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// A queue of one: a burst collapses to the newest label. A goroutine per
|
||||||
|
// update could apply them out of order
|
||||||
|
func (u *labelUpdater) update(label string) {
|
||||||
|
u.mu.Lock()
|
||||||
|
defer u.mu.Unlock()
|
||||||
|
u.pending = &label
|
||||||
|
if u.running {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.running = true
|
||||||
|
go u.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ends once caught up, so nothing needs cancelling when Run returns
|
||||||
|
func (u *labelUpdater) run() {
|
||||||
|
for {
|
||||||
|
u.mu.Lock()
|
||||||
|
label := u.pending
|
||||||
|
if label == nil {
|
||||||
|
u.running = false
|
||||||
|
u.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.pending = nil
|
||||||
|
u.mu.Unlock()
|
||||||
|
u.set(*label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Updates the label on the native border, or nil when it is not fzf's to update
|
||||||
|
func nativeLabelSetter() func(string) {
|
||||||
|
// Read both, so neither is left behind
|
||||||
|
tmuxPane, zellijPane := takeEnv(tmuxBorderLabelEnv), takeEnv(zellijBorderLabelEnv)
|
||||||
|
var set func(string)
|
||||||
|
switch {
|
||||||
|
case tmuxPane != "":
|
||||||
|
set = func(label string) { setTmuxBorderLabel(tmuxPane, label) }
|
||||||
|
case zellijPane != "":
|
||||||
|
set = func(label string) { setZellijBorderLabel(zellijPane, label) }
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
updater := &labelUpdater{set: set}
|
||||||
|
return updater.update
|
||||||
|
}
|
||||||
|
|
||||||
|
func popupArgStr(args []string, opts *Options) (string, string) {
|
||||||
|
fzf, rest := args[0], args[1:]
|
||||||
|
args = []string{"--bind=ctrl-z:ignore"}
|
||||||
|
if !opts.Tmux.border && (opts.BorderShape == tui.BorderUndefined || opts.BorderShape == tui.BorderLine) {
|
||||||
|
if tui.DefaultBorderShape == tui.BorderRounded {
|
||||||
|
rest = append(rest, "--border=rounded")
|
||||||
|
} else {
|
||||||
|
rest = append(rest, "--border=sharp")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if opts.Tmux.border && opts.Margin == defaultMargin() {
|
||||||
|
args = append(args, "--margin=0,1")
|
||||||
|
}
|
||||||
|
argStr := escapeSingleQuote(fzf)
|
||||||
|
for _, arg := range append(args, rest...) {
|
||||||
|
argStr += " " + escapeSingleQuote(arg)
|
||||||
|
}
|
||||||
|
argStr += ` --no-popup --no-height`
|
||||||
|
|
||||||
|
dir, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
dir = "."
|
||||||
|
}
|
||||||
|
return argStr, dir
|
||||||
|
}
|
||||||
|
|
||||||
func fifo(name string) (string, error) {
|
func fifo(name string) (string, error) {
|
||||||
ns := time.Now().UnixNano()
|
ns := time.Now().UnixNano()
|
||||||
output := filepath.Join(os.TempDir(), fmt.Sprintf("fzf-%s-%d", name, ns))
|
output := filepath.Join(os.TempDir(), fmt.Sprintf("fzf-%s-%d", name, ns))
|
||||||
@@ -99,6 +191,9 @@ func runProxy(commandPrefix string, cmdBuilder func(temp string, needBash bool)
|
|||||||
validIdentifier := regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
|
validIdentifier := regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
|
||||||
for _, pairStr := range os.Environ() {
|
for _, pairStr := range os.Environ() {
|
||||||
pair := strings.SplitN(pairStr, "=", 2)
|
pair := strings.SplitN(pairStr, "=", 2)
|
||||||
|
if strings.HasPrefix(pair[0], internalEnvPrefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if validIdentifier.MatchString(pair[0]) {
|
if validIdentifier.MatchString(pair[0]) {
|
||||||
exports = append(exports, fmt.Sprintf("export %s=%s", pair[0], escapeSingleQuote(pair[1])))
|
exports = append(exports, fmt.Sprintf("export %s=%s", pair[0], escapeSingleQuote(pair[1])))
|
||||||
} else if strings.HasPrefix(pair[0], "BASH_FUNC_") && strings.HasSuffix(pair[0], "%%") {
|
} else if strings.HasPrefix(pair[0], "BASH_FUNC_") && strings.HasSuffix(pair[0], "%%") {
|
||||||
@@ -112,6 +207,17 @@ func runProxy(commandPrefix string, cmdBuilder func(temp string, needBash bool)
|
|||||||
temp := WriteTemporaryFile(append(exports, command), "\n")
|
temp := WriteTemporaryFile(append(exports, command), "\n")
|
||||||
defer os.Remove(temp)
|
defer os.Remove(temp)
|
||||||
|
|
||||||
|
// Pre-create the become file so that another user on a shared TMPDIR
|
||||||
|
// cannot plant a file or a symbolic link at the predictable path while
|
||||||
|
// fzf is running
|
||||||
|
becomeFile := temp + becomeSuffix
|
||||||
|
becomeF, err := os.OpenFile(becomeFile, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
return ExitError, err
|
||||||
|
}
|
||||||
|
becomeF.Close()
|
||||||
|
defer os.Remove(becomeFile)
|
||||||
|
|
||||||
cmd, err := cmdBuilder(temp, needBash)
|
cmd, err := cmdBuilder(temp, needBash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ExitError, err
|
return ExitError, err
|
||||||
@@ -129,16 +235,15 @@ func runProxy(commandPrefix string, cmdBuilder func(temp string, needBash bool)
|
|||||||
if exitError, ok := err.(*exec.ExitError); ok {
|
if exitError, ok := err.(*exec.ExitError); ok {
|
||||||
code := exitError.ExitCode()
|
code := exitError.ExitCode()
|
||||||
if code == ExitBecome {
|
if code == ExitBecome {
|
||||||
becomeFile := temp + becomeSuffix
|
|
||||||
data, err := os.ReadFile(becomeFile)
|
data, err := os.ReadFile(becomeFile)
|
||||||
os.Remove(becomeFile)
|
os.Remove(becomeFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ExitError, err
|
return ExitError, err
|
||||||
}
|
}
|
||||||
elems := strings.Split(string(data), "\x00")
|
if len(data) == 0 {
|
||||||
if len(elems) < 1 {
|
|
||||||
return ExitError, errors.New("invalid become command")
|
return ExitError, errors.New("invalid become command")
|
||||||
}
|
}
|
||||||
|
elems := strings.Split(string(data), "\x00")
|
||||||
command := elems[0]
|
command := elems[0]
|
||||||
env := []string{}
|
env := []string{}
|
||||||
if len(elems) > 1 {
|
if len(elems) > 1 {
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package fzf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLabelUpdater(t *testing.T) {
|
||||||
|
var mu sync.Mutex
|
||||||
|
applied := []int{}
|
||||||
|
// The goroutine can still be running after the test function returns, so
|
||||||
|
// it records the label instead of reporting it, and -1 stands in for one
|
||||||
|
// that could not be parsed
|
||||||
|
updater := &labelUpdater{set: func(label string) {
|
||||||
|
n, err := strconv.Atoi(label)
|
||||||
|
if err != nil {
|
||||||
|
n = -1
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
applied = append(applied, n)
|
||||||
|
mu.Unlock()
|
||||||
|
// Long enough for the burst to pile up behind the first update
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}}
|
||||||
|
|
||||||
|
const last = 99
|
||||||
|
for i := 0; i <= last; i++ {
|
||||||
|
updater.update(strconv.Itoa(i))
|
||||||
|
}
|
||||||
|
|
||||||
|
// However many updates are dropped, the final one is always applied
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for {
|
||||||
|
mu.Lock()
|
||||||
|
done := len(applied) > 0 && applied[len(applied)-1] == last
|
||||||
|
mu.Unlock()
|
||||||
|
if done {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("last update was not applied: %v", applied)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
// Updates are never applied out of order, so the border cannot be left
|
||||||
|
// showing a label older than the last one requested
|
||||||
|
for i, n := range applied {
|
||||||
|
if i > 0 && n <= applied[i-1] {
|
||||||
|
t.Errorf("applied out of order: %v", applied)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A burst collapses instead of running one command per update. The
|
||||||
|
// updates are queued in a tight loop while each one takes a millisecond
|
||||||
|
// to apply, so all of them landing would mean no coalescing at all
|
||||||
|
if len(applied) >= last+1 {
|
||||||
|
t.Errorf("expected updates to be coalesced, applied all %d", len(applied))
|
||||||
|
}
|
||||||
|
t.Logf("applied %d of %d updates", len(applied), last+1)
|
||||||
|
}
|
||||||
@@ -274,6 +274,24 @@ func (r *Reader) readFiles(roots []string, opts walkerOpts, ignores []string) bo
|
|||||||
ToSlash: fastwalk.DefaultToSlash(),
|
ToSlash: fastwalk.DefaultToSlash(),
|
||||||
Sort: fastwalk.SortFilesFirst,
|
Sort: fastwalk.SortFilesFirst,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When following symlinks, precompute the absolute real paths of walker
|
||||||
|
// roots so we can skip symlinks that point to an ancestor. fastwalk's
|
||||||
|
// built-in loop detection (shouldTraverse) catches loops on the second
|
||||||
|
// pass, but a single pass through a symlink like z: -> / already
|
||||||
|
// traverses the entire root filesystem, causing severe resource
|
||||||
|
// exhaustion. Skipping ancestor symlinks prevents this entirely.
|
||||||
|
var absRoots []string
|
||||||
|
if opts.follow {
|
||||||
|
for _, root := range roots {
|
||||||
|
if real, err := filepath.EvalSymlinks(root); err == nil {
|
||||||
|
if abs, err := filepath.Abs(real); err == nil {
|
||||||
|
absRoots = append(absRoots, filepath.Clean(abs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ignoresBase := []string{}
|
ignoresBase := []string{}
|
||||||
ignoresFull := []string{}
|
ignoresFull := []string{}
|
||||||
ignoresSuffix := []string{}
|
ignoresSuffix := []string{}
|
||||||
@@ -307,6 +325,24 @@ func (r *Reader) readFiles(roots []string, opts walkerOpts, ignores []string) bo
|
|||||||
if isDirSymlink && !opts.follow {
|
if isDirSymlink && !opts.follow {
|
||||||
return filepath.SkipDir
|
return filepath.SkipDir
|
||||||
}
|
}
|
||||||
|
// Skip symlinks whose target is an ancestor of (or equal to)
|
||||||
|
// any walker root. Following such symlinks would traverse a
|
||||||
|
// superset of the tree we're already walking.
|
||||||
|
if isDirSymlink && len(absRoots) > 0 {
|
||||||
|
if target, err := filepath.EvalSymlinks(path); err == nil {
|
||||||
|
if abs, err := filepath.Abs(target); err == nil {
|
||||||
|
abs = filepath.Clean(abs)
|
||||||
|
if abs == string(os.PathSeparator) {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
for _, absRoot := range absRoots {
|
||||||
|
if absRoot == abs || strings.HasPrefix(absRoot, abs+string(os.PathSeparator)) {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
isDir := de.IsDir() || isDirSymlink
|
isDir := de.IsDir() || isDirSymlink
|
||||||
if isDir {
|
if isDir {
|
||||||
base := filepath.Base(path)
|
base := filepath.Base(path)
|
||||||
|
|||||||
+126
-26
@@ -2,6 +2,7 @@ package fzf
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"math"
|
"math"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
@@ -30,11 +31,9 @@ type Result struct {
|
|||||||
|
|
||||||
func buildResult(item *Item, offsets []Offset, score int) Result {
|
func buildResult(item *Item, offsets []Offset, score int) Result {
|
||||||
if len(offsets) > 1 {
|
if len(offsets) > 1 {
|
||||||
sort.Sort(ByOrder(offsets))
|
slices.SortFunc(offsets, compareOffsets)
|
||||||
}
|
}
|
||||||
|
|
||||||
result := Result{item: item}
|
|
||||||
numChars := item.text.Length()
|
|
||||||
minBegin := math.MaxUint16
|
minBegin := math.MaxUint16
|
||||||
minEnd := math.MaxUint16
|
minEnd := math.MaxUint16
|
||||||
maxEnd := 0
|
maxEnd := 0
|
||||||
@@ -49,6 +48,14 @@ func buildResult(item *Item, offsets []Offset, score int) Result {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return buildResultFromBounds(item, score, minBegin, minEnd, maxEnd, validOffsetFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildResultFromBounds builds a Result from pre-computed offset bounds.
|
||||||
|
func buildResultFromBounds(item *Item, score int, minBegin, minEnd, maxEnd int, validOffsetFound bool) Result {
|
||||||
|
result := Result{item: item}
|
||||||
|
numChars := item.text.Length()
|
||||||
|
|
||||||
for idx, criterion := range sortCriteria {
|
for idx, criterion := range sortCriteria {
|
||||||
val := uint16(math.MaxUint16)
|
val := uint16(math.MaxUint16)
|
||||||
switch criterion {
|
switch criterion {
|
||||||
@@ -75,7 +82,6 @@ func buildResult(item *Item, offsets []Offset, score int) Result {
|
|||||||
val = item.TrimLength()
|
val = item.TrimLength()
|
||||||
case byPathname:
|
case byPathname:
|
||||||
if validOffsetFound {
|
if validOffsetFound {
|
||||||
// lastDelim := strings.LastIndexByte(item.text.ToString(), '/')
|
|
||||||
lastDelim := -1
|
lastDelim := -1
|
||||||
s := item.text.ToString()
|
s := item.text.ToString()
|
||||||
for i := len(s) - 1; i >= 0; i-- {
|
for i := len(s) - 1; i >= 0; i-- {
|
||||||
@@ -123,7 +129,7 @@ func minRank() Result {
|
|||||||
return Result{item: &minItem, points: [4]uint16{math.MaxUint16, 0, 0, 0}}
|
return Result{item: &minItem, points: [4]uint16{math.MaxUint16, 0, 0, 0}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, theme *tui.ColorTheme, colBase tui.ColorPair, colMatch tui.ColorPair, attrNth tui.Attr, hidden bool) []colorOffset {
|
func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, theme *tui.ColorTheme, colBase tui.ColorPair, colMatch tui.ColorPair, attrNth tui.Attr, nthOverlay tui.Attr, hidden bool) []colorOffset {
|
||||||
itemColors := result.item.Colors()
|
itemColors := result.item.Colors()
|
||||||
|
|
||||||
// No ANSI codes
|
// No ANSI codes
|
||||||
@@ -182,7 +188,7 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// sort.Sort(ByOrder(offsets))
|
// slices.SortFunc(offsets, compareOffsets)
|
||||||
|
|
||||||
// Merge offsets
|
// Merge offsets
|
||||||
// ------------ ---- -- ----
|
// ------------ ---- -- ----
|
||||||
@@ -192,7 +198,10 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
|
|||||||
start := 0
|
start := 0
|
||||||
ansiToColorPair := func(ansi ansiOffset, base tui.ColorPair) tui.ColorPair {
|
ansiToColorPair := func(ansi ansiOffset, base tui.ColorPair) tui.ColorPair {
|
||||||
if !theme.Colored {
|
if !theme.Colored {
|
||||||
return tui.NewColorPair(-1, -1, ansi.color.attr).MergeAttr(base)
|
// Ignore ANSI colors but keep the attributes. Retain the base
|
||||||
|
// colors (e.g. an overridden input-bg or list-bg) instead of
|
||||||
|
// resetting to the terminal default.
|
||||||
|
return tui.NewColorPair(base.Fg(), base.Bg(), ansi.color.attr).MergeAttr(base)
|
||||||
}
|
}
|
||||||
// fd --color always | fzf --ansi --delimiter / --nth -1 --color fg:dim:strip,nth:regular
|
// fd --color always | fzf --ansi --delimiter / --nth -1 --color fg:dim:strip,nth:regular
|
||||||
if base.ShouldStripColors() {
|
if base.ShouldStripColors() {
|
||||||
@@ -208,6 +217,10 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
|
|||||||
}
|
}
|
||||||
return tui.NewColorPair(fg, bg, ansi.color.attr).WithUl(ansi.color.ul).MergeAttr(base)
|
return tui.NewColorPair(fg, bg, ansi.color.attr).WithUl(ansi.color.ul).MergeAttr(base)
|
||||||
}
|
}
|
||||||
|
fgAttr := tui.ColNormal.Attr()
|
||||||
|
nthAttrFinal := fgAttr.Merge(attrNth).Merge(nthOverlay)
|
||||||
|
nthBase := colBase.WithNewAttr(nthAttrFinal)
|
||||||
|
|
||||||
var colors []colorOffset
|
var colors []colorOffset
|
||||||
add := func(idx int) {
|
add := func(idx int) {
|
||||||
if curr.fbg >= 0 {
|
if curr.fbg >= 0 {
|
||||||
@@ -221,7 +234,7 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
|
|||||||
if curr.match {
|
if curr.match {
|
||||||
var color tui.ColorPair
|
var color tui.ColorPair
|
||||||
if curr.nth {
|
if curr.nth {
|
||||||
color = colBase.WithAttr(attrNth).Merge(colMatch)
|
color = nthBase.Merge(colMatch)
|
||||||
} else {
|
} else {
|
||||||
color = colBase.Merge(colMatch)
|
color = colBase.Merge(colMatch)
|
||||||
}
|
}
|
||||||
@@ -241,7 +254,7 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
|
|||||||
if color.Fg().IsDefault() && origColor.HasBg() {
|
if color.Fg().IsDefault() && origColor.HasBg() {
|
||||||
color = origColor
|
color = origColor
|
||||||
if curr.nth {
|
if curr.nth {
|
||||||
color = color.WithAttr(attrNth &^ tui.AttrRegular)
|
color = color.WithAttr((attrNth &^ tui.AttrRegular).Merge(nthOverlay))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
color = origColor.MergeNonDefault(color)
|
color = origColor.MergeNonDefault(color)
|
||||||
@@ -253,7 +266,7 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
|
|||||||
ansi := itemColors[curr.index]
|
ansi := itemColors[curr.index]
|
||||||
base := colBase
|
base := colBase
|
||||||
if curr.nth {
|
if curr.nth {
|
||||||
base = base.WithAttr(attrNth)
|
base = nthBase
|
||||||
}
|
}
|
||||||
if hidden {
|
if hidden {
|
||||||
base = base.WithFg(theme.Nomatch)
|
base = base.WithFg(theme.Nomatch)
|
||||||
@@ -265,7 +278,7 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
|
|||||||
match: false,
|
match: false,
|
||||||
url: ansi.color.url})
|
url: ansi.color.url})
|
||||||
} else {
|
} else {
|
||||||
color := colBase.WithAttr(attrNth)
|
color := nthBase
|
||||||
if hidden {
|
if hidden {
|
||||||
color = color.WithFg(theme.Nomatch)
|
color = color.WithFg(theme.Nomatch)
|
||||||
}
|
}
|
||||||
@@ -288,21 +301,20 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
|
|||||||
return colors
|
return colors
|
||||||
}
|
}
|
||||||
|
|
||||||
// ByOrder is for sorting substring offsets
|
func compareOffsets(a, b Offset) int {
|
||||||
type ByOrder []Offset
|
if a[0] < b[0] {
|
||||||
|
return -1
|
||||||
func (a ByOrder) Len() int {
|
}
|
||||||
return len(a)
|
if a[0] > b[0] {
|
||||||
}
|
return 1
|
||||||
|
}
|
||||||
func (a ByOrder) Swap(i, j int) {
|
if a[1] < b[1] {
|
||||||
a[i], a[j] = a[j], a[i]
|
return -1
|
||||||
}
|
}
|
||||||
|
if a[1] > b[1] {
|
||||||
func (a ByOrder) Less(i, j int) bool {
|
return 1
|
||||||
ioff := a[i]
|
}
|
||||||
joff := a[j]
|
return 0
|
||||||
return (ioff[0] < joff[0]) || (ioff[0] == joff[0]) && (ioff[1] <= joff[1])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ByRelevance is for sorting Items
|
// ByRelevance is for sorting Items
|
||||||
@@ -334,3 +346,91 @@ func (a ByRelevanceTac) Swap(i, j int) {
|
|||||||
func (a ByRelevanceTac) Less(i, j int) bool {
|
func (a ByRelevanceTac) Less(i, j int) bool {
|
||||||
return compareRanks(a[i], a[j], true)
|
return compareRanks(a[i], a[j], true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// radixSortResults sorts Results by their points key using LSD radix sort.
|
||||||
|
// O(n) time complexity vs O(n log n) for comparison sort.
|
||||||
|
// The sort is stable, so equal-key items maintain original (item-index) order.
|
||||||
|
// For tac mode, runs of equal keys are reversed after sorting.
|
||||||
|
func radixSortResults(a []Result, tac bool, scratch []Result) []Result {
|
||||||
|
n := len(a)
|
||||||
|
if n < 128 {
|
||||||
|
if tac {
|
||||||
|
sort.Sort(ByRelevanceTac(a))
|
||||||
|
} else {
|
||||||
|
sort.Sort(ByRelevance(a))
|
||||||
|
}
|
||||||
|
return scratch[:0]
|
||||||
|
}
|
||||||
|
|
||||||
|
if cap(scratch) < n {
|
||||||
|
scratch = make([]Result, n)
|
||||||
|
}
|
||||||
|
buf := scratch[:n]
|
||||||
|
src, dst := a, buf
|
||||||
|
scattered := 0
|
||||||
|
|
||||||
|
// OR of all keys: a byte position that is zero here is zero in every key,
|
||||||
|
// so its pass is a no-op and can be skipped without a histogram scan.
|
||||||
|
// With the common two-criteria setup the low 32 bits are always zero.
|
||||||
|
var keyOR uint64
|
||||||
|
for i := range src {
|
||||||
|
keyOR |= sortKey(&src[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
for pass := range 8 {
|
||||||
|
shift := uint(pass) * 8
|
||||||
|
|
||||||
|
if byte(keyOR>>shift) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var count [256]int
|
||||||
|
for i := range src {
|
||||||
|
count[byte(sortKey(&src[i])>>shift)]++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if all items have the same byte value at this position
|
||||||
|
if count[byte(sortKey(&src[0])>>shift)] == n {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var offset [256]int
|
||||||
|
for i := 1; i < 256; i++ {
|
||||||
|
offset[i] = offset[i-1] + count[i-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range src {
|
||||||
|
b := byte(sortKey(&src[i]) >> shift)
|
||||||
|
dst[offset[b]] = src[i]
|
||||||
|
offset[b]++
|
||||||
|
}
|
||||||
|
|
||||||
|
src, dst = dst, src
|
||||||
|
scattered++
|
||||||
|
}
|
||||||
|
|
||||||
|
// If odd number of scatters, data is in buf, copy back to a
|
||||||
|
if scattered%2 == 1 {
|
||||||
|
copy(a, src)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle tac: reverse runs of equal keys so equal-key items
|
||||||
|
// are in reverse item-index order
|
||||||
|
if tac {
|
||||||
|
i := 0
|
||||||
|
for i < n {
|
||||||
|
ki := sortKey(&a[i])
|
||||||
|
j := i + 1
|
||||||
|
for j < n && sortKey(&a[j]) == ki {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
if j-i > 1 {
|
||||||
|
for l, r := i, j-1; l < r; l, r = l+1, r-1 {
|
||||||
|
a[l], a[r] = a[r], a[l]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i = j
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scratch
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//go:build !386 && !amd64
|
//go:build !386 && !amd64 && !arm64
|
||||||
|
|
||||||
package fzf
|
package fzf
|
||||||
|
|
||||||
@@ -14,3 +14,7 @@ func compareRanks(irank Result, jrank Result, tac bool) bool {
|
|||||||
}
|
}
|
||||||
return (irank.item.Index() <= jrank.item.Index()) != tac
|
return (irank.item.Index() <= jrank.item.Index()) != tac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sortKey(r *Result) uint64 {
|
||||||
|
return uint64(r.points[0]) | uint64(r.points[1])<<16 | uint64(r.points[2])<<32 | uint64(r.points[3])<<48
|
||||||
|
}
|
||||||
|
|||||||
+93
-3
@@ -2,6 +2,8 @@ package fzf
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"math"
|
"math"
|
||||||
|
"math/rand"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -18,7 +20,7 @@ func TestOffsetSort(t *testing.T) {
|
|||||||
offsets := []Offset{
|
offsets := []Offset{
|
||||||
{3, 5}, {2, 7},
|
{3, 5}, {2, 7},
|
||||||
{1, 3}, {2, 9}}
|
{1, 3}, {2, 9}}
|
||||||
sort.Sort(ByOrder(offsets))
|
slices.SortFunc(offsets, compareOffsets)
|
||||||
|
|
||||||
if offsets[0][0] != 1 || offsets[0][1] != 3 ||
|
if offsets[0][0] != 1 || offsets[0][1] != 3 ||
|
||||||
offsets[1][0] != 2 || offsets[1][1] != 7 ||
|
offsets[1][0] != 2 || offsets[1][1] != 7 ||
|
||||||
@@ -131,7 +133,7 @@ func TestColorOffset(t *testing.T) {
|
|||||||
|
|
||||||
colBase := tui.NewColorPair(89, 189, tui.AttrUndefined)
|
colBase := tui.NewColorPair(89, 189, tui.AttrUndefined)
|
||||||
colMatch := tui.NewColorPair(99, 199, tui.AttrUndefined)
|
colMatch := tui.NewColorPair(99, 199, tui.AttrUndefined)
|
||||||
colors := item.colorOffsets(offsets, nil, tui.Dark256, colBase, colMatch, tui.AttrUndefined, false)
|
colors := item.colorOffsets(offsets, nil, tui.Dark256, colBase, colMatch, tui.AttrUndefined, 0, false)
|
||||||
assert := func(idx int, b int32, e int32, c tui.ColorPair) {
|
assert := func(idx int, b int32, e int32, c tui.ColorPair) {
|
||||||
o := colors[idx]
|
o := colors[idx]
|
||||||
if o.offset[0] != b || o.offset[1] != e || o.color != c {
|
if o.offset[0] != b || o.offset[1] != e || o.color != c {
|
||||||
@@ -158,7 +160,7 @@ func TestColorOffset(t *testing.T) {
|
|||||||
|
|
||||||
nthOffsets := []Offset{{37, 39}, {42, 45}}
|
nthOffsets := []Offset{{37, 39}, {42, 45}}
|
||||||
for _, attr := range []tui.Attr{tui.AttrRegular, tui.StrikeThrough} {
|
for _, attr := range []tui.Attr{tui.AttrRegular, tui.StrikeThrough} {
|
||||||
colors = item.colorOffsets(offsets, nthOffsets, tui.Dark256, colRegular, colUnderline, attr, false)
|
colors = item.colorOffsets(offsets, nthOffsets, tui.Dark256, colRegular, colUnderline, attr, 0, false)
|
||||||
|
|
||||||
// [{[0 5] {1 5 0}} {[5 15] {1 5 8}} {[15 20] {1 5 0}}
|
// [{[0 5] {1 5 0}} {[5 15] {1 5 8}} {[15 20] {1 5 0}}
|
||||||
// {[22 25] {2 6 1}} {[25 27] {2 6 9}} {[27 30] {-1 -1 8}}
|
// {[22 25] {2 6 1}} {[25 27] {2 6 9}} {[27 30] {-1 -1 8}}
|
||||||
@@ -181,4 +183,92 @@ func TestColorOffset(t *testing.T) {
|
|||||||
assert(10, 37, 39, tui.NewColorPair(4, 8, expected))
|
assert(10, 37, 39, tui.NewColorPair(4, 8, expected))
|
||||||
assert(11, 39, 40, tui.NewColorPair(4, 8, tui.Bold))
|
assert(11, 39, 40, tui.NewColorPair(4, 8, tui.Bold))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test nthOverlay: simulates nth:regular with current-fg:underline
|
||||||
|
// The overlay (underline) should survive even though nth:regular clears attrs.
|
||||||
|
// Precedence: fg < nth < current-fg
|
||||||
|
colors = item.colorOffsets(offsets, nthOffsets, tui.Dark256, colRegular, colUnderline, tui.AttrRegular, tui.Underline, false)
|
||||||
|
|
||||||
|
// nth regions should have Underline (from overlay), not cleared by AttrRegular
|
||||||
|
// Non-nth regions keep colBase attrs (AttrUndefined)
|
||||||
|
assert(0, 0, 5, tui.NewColorPair(1, 5, tui.AttrUndefined))
|
||||||
|
assert(1, 5, 15, tui.NewColorPair(1, 5, tui.Underline))
|
||||||
|
assert(2, 15, 20, tui.NewColorPair(1, 5, tui.AttrUndefined))
|
||||||
|
assert(3, 22, 25, tui.NewColorPair(2, 6, tui.Bold))
|
||||||
|
assert(4, 25, 27, tui.NewColorPair(2, 6, tui.Bold|tui.Underline))
|
||||||
|
assert(5, 27, 30, colUnderline)
|
||||||
|
assert(6, 30, 32, tui.NewColorPair(3, 7, tui.Underline))
|
||||||
|
assert(7, 32, 33, colUnderline)
|
||||||
|
assert(8, 33, 35, tui.NewColorPair(4, 8, tui.Bold|tui.Underline))
|
||||||
|
assert(9, 35, 37, tui.NewColorPair(4, 8, tui.Bold))
|
||||||
|
// nth region within ANSI bold: AttrRegular clears, overlay adds Underline back
|
||||||
|
assert(10, 37, 39, tui.NewColorPair(4, 8, tui.Bold|tui.Underline))
|
||||||
|
assert(11, 39, 40, tui.NewColorPair(4, 8, tui.Bold))
|
||||||
|
|
||||||
|
// Test nthOverlay with additive attrs: nth:strikethrough with selected-fg:bold
|
||||||
|
colors = item.colorOffsets(offsets, nthOffsets, tui.Dark256, colRegular, colUnderline, tui.StrikeThrough, tui.Bold, false)
|
||||||
|
|
||||||
|
// Non-nth entries unchanged from overlay=0 case
|
||||||
|
assert(0, 0, 5, tui.NewColorPair(1, 5, tui.AttrUndefined))
|
||||||
|
assert(5, 27, 30, colUnderline) // match only, no nth
|
||||||
|
assert(7, 32, 33, colUnderline) // match only, no nth
|
||||||
|
// nth region within ANSI bold: StrikeThrough|Bold merged with ANSI Bold
|
||||||
|
assert(10, 37, 39, tui.NewColorPair(4, 8, tui.Bold|tui.StrikeThrough))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRadixSortResults(t *testing.T) {
|
||||||
|
sortCriteria = []criterion{byScore, byLength}
|
||||||
|
|
||||||
|
rng := rand.New(rand.NewSource(42))
|
||||||
|
|
||||||
|
for _, n := range []int{128, 256, 500, 1000} {
|
||||||
|
for _, tac := range []bool{false, true} {
|
||||||
|
// Build items with random points and indices
|
||||||
|
items := make([]*Item, n)
|
||||||
|
for i := range items {
|
||||||
|
items[i] = &Item{text: util.Chars{Index: int32(i)}}
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]Result, n)
|
||||||
|
for i := range results {
|
||||||
|
results[i] = Result{
|
||||||
|
item: items[i],
|
||||||
|
points: [4]uint16{
|
||||||
|
uint16(rng.Intn(256)),
|
||||||
|
uint16(rng.Intn(256)),
|
||||||
|
uint16(rng.Intn(256)),
|
||||||
|
uint16(rng.Intn(256)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make some duplicates to test stability
|
||||||
|
for i := 0; i < n/4; i++ {
|
||||||
|
j := rng.Intn(n)
|
||||||
|
k := rng.Intn(n)
|
||||||
|
results[j].points = results[k].points
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy for reference sort
|
||||||
|
expected := make([]Result, n)
|
||||||
|
copy(expected, results)
|
||||||
|
if tac {
|
||||||
|
sort.Sort(ByRelevanceTac(expected))
|
||||||
|
} else {
|
||||||
|
sort.Sort(ByRelevance(expected))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Radix sort
|
||||||
|
var scratch []Result
|
||||||
|
scratch = radixSortResults(results, tac, scratch)
|
||||||
|
|
||||||
|
for i := range results {
|
||||||
|
if results[i] != expected[i] {
|
||||||
|
t.Errorf("n=%d tac=%v: mismatch at index %d: got item %d, want item %d",
|
||||||
|
n, tac, i, results[i].item.Index(), expected[i].item.Index())
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -1,4 +1,4 @@
|
|||||||
//go:build 386 || amd64
|
//go:build 386 || amd64 || arm64
|
||||||
|
|
||||||
package fzf
|
package fzf
|
||||||
|
|
||||||
@@ -14,3 +14,7 @@ func compareRanks(irank Result, jrank Result, tac bool) bool {
|
|||||||
}
|
}
|
||||||
return (irank.item.Index() <= jrank.item.Index()) != tac
|
return (irank.item.Index() <= jrank.item.Index()) != tac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sortKey(r *Result) uint64 {
|
||||||
|
return *(*uint64)(unsafe.Pointer(&r.points[0]))
|
||||||
|
}
|
||||||
|
|||||||
+6
-6
@@ -122,13 +122,12 @@ func startHttpServer(address listenAddress, actionChannel chan []*action, getHan
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
server := httpServer{
|
server := httpServer{
|
||||||
apiKey: []byte(apiKey),
|
apiKey: []byte(apiKey),
|
||||||
actionChannel: actionChannel,
|
actionChannel: actionChannel,
|
||||||
getHandler: getHandler,
|
getHandler: getHandler,
|
||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
|
||||||
for {
|
for {
|
||||||
conn, err := listener.Accept()
|
conn, err := listener.Accept()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -154,7 +153,7 @@ func startHttpServer(address listenAddress, actionChannel chan []*action, getHan
|
|||||||
func (server *httpServer) handleHttpRequest(conn net.Conn) string {
|
func (server *httpServer) handleHttpRequest(conn net.Conn) string {
|
||||||
contentLength := 0
|
contentLength := 0
|
||||||
apiKey := ""
|
apiKey := ""
|
||||||
body := ""
|
var bodyBuilder strings.Builder
|
||||||
answer := func(code string, message string) string {
|
answer := func(code string, message string) string {
|
||||||
message += "\n"
|
message += "\n"
|
||||||
return code + fmt.Sprintf("Content-Length: %d%s", len(message), crlf+crlf+message)
|
return code + fmt.Sprintf("Content-Length: %d%s", len(message), crlf+crlf+message)
|
||||||
@@ -176,7 +175,7 @@ func (server *httpServer) handleHttpRequest(conn net.Conn) string {
|
|||||||
token := data[:found+len(crlf)]
|
token := data[:found+len(crlf)]
|
||||||
return len(token), token, nil
|
return len(token), token, nil
|
||||||
}
|
}
|
||||||
if atEOF || len(body)+len(data) >= contentLength {
|
if atEOF || bodyBuilder.Len()+len(data) >= contentLength {
|
||||||
return 0, data, bufio.ErrFinalToken
|
return 0, data, bufio.ErrFinalToken
|
||||||
}
|
}
|
||||||
return 0, nil, nil
|
return 0, nil, nil
|
||||||
@@ -219,7 +218,7 @@ Loop:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 2: // Request body
|
case 2: // Request body
|
||||||
body += text
|
bodyBuilder.WriteString(text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,12 +234,13 @@ Loop:
|
|||||||
return answer(httpUnavailable+jsonContentType, `{"error":"timeout"}`)
|
return answer(httpUnavailable+jsonContentType, `{"error":"timeout"}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body := bodyBuilder.String()
|
||||||
if len(body) < contentLength {
|
if len(body) < contentLength {
|
||||||
return bad("incomplete request")
|
return bad("incomplete request")
|
||||||
}
|
}
|
||||||
body = body[:contentLength]
|
body = body[:contentLength]
|
||||||
|
|
||||||
actions, err := parseSingleActionList(strings.Trim(string(body), "\r\n"))
|
actions, err := parseSingleActionList(strings.Trim(string(body), "\r\n"), false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return bad(err.Error())
|
return bad(err.Error())
|
||||||
}
|
}
|
||||||
|
|||||||
+1420
-243
File diff suppressed because it is too large
Load Diff
+163
-10
@@ -223,7 +223,7 @@ func TestReplacePlaceholder(t *testing.T) {
|
|||||||
// while the double q is invalid, it is useful here for testing purposes
|
// while the double q is invalid, it is useful here for testing purposes
|
||||||
templateToOutput[`{q}`] = "{{.O}}" + query + "{{.O}}"
|
templateToOutput[`{q}`] = "{{.O}}" + query + "{{.O}}"
|
||||||
templateToOutput[`{fzf:query}`] = "{{.O}}" + query + "{{.O}}"
|
templateToOutput[`{fzf:query}`] = "{{.O}}" + query + "{{.O}}"
|
||||||
templateToOutput[`{fzf:action} {fzf:prompt}`] = "backward-delete-char-eof 'prompt'"
|
templateToOutput[`{fzf:action} {fzf:prompt}`] = `backward-delete-char-eof {{.O}}prompt{{.O}}`
|
||||||
|
|
||||||
// IV. escaping placeholder
|
// IV. escaping placeholder
|
||||||
templateToOutput[`\{}`] = `{}`
|
templateToOutput[`\{}`] = `{}`
|
||||||
@@ -251,9 +251,9 @@ func TestReplacePlaceholder(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestQuoteEntry(t *testing.T) {
|
func TestQuoteEntry(t *testing.T) {
|
||||||
type quotes struct{ E, O, SQ, DQ, BS string } // standalone escape, outer, single and double quotes, backslash
|
type quotes struct{ E, O, SQ, DQ, BS, PB string } // standalone escape, outer, single and double quotes, doubled and plain backslash
|
||||||
unixStyle := quotes{``, `'`, `'\''`, `"`, `\`}
|
unixStyle := quotes{``, `'`, `'\''`, `"`, `\`, `\`}
|
||||||
windowsStyle := quotes{`^`, `^"`, `'`, `\^"`, `\\`}
|
windowsStyle := quotes{`^`, `^"`, `'`, `\^"`, `\\`, `\`}
|
||||||
var effectiveStyle quotes
|
var effectiveStyle quotes
|
||||||
exec := util.NewExecutor("")
|
exec := util.NewExecutor("")
|
||||||
|
|
||||||
@@ -280,13 +280,13 @@ func TestQuoteEntry(t *testing.T) {
|
|||||||
`>`: `{{.O}}{{.E}}>{{.O}}`,
|
`>`: `{{.O}}{{.E}}>{{.O}}`,
|
||||||
`(`: `{{.O}}{{.E}}({{.O}}`,
|
`(`: `{{.O}}{{.E}}({{.O}}`,
|
||||||
`)`: `{{.O}}{{.E}}){{.O}}`,
|
`)`: `{{.O}}{{.E}}){{.O}}`,
|
||||||
`@`: `{{.O}}{{.E}}@{{.O}}`,
|
`@`: `{{.O}}@{{.O}}`,
|
||||||
`^`: `{{.O}}{{.E}}^{{.O}}`,
|
`^`: `{{.O}}{{.E}}^{{.O}}`,
|
||||||
`%`: `{{.O}}{{.E}}%{{.O}}`,
|
`%`: `{{.O}}{{.E}}%{{.O}}`,
|
||||||
`!`: `{{.O}}{{.E}}!{{.O}}`,
|
`!`: `{{.O}}{{.E}}!{{.O}}`,
|
||||||
`%USERPROFILE%`: `{{.O}}{{.E}}%USERPROFILE{{.E}}%{{.O}}`,
|
`%USERPROFILE%`: `{{.O}}{{.E}}%USERPROFILE{{.E}}%{{.O}}`,
|
||||||
`C:\Program Files (x86)\`: `{{.O}}C:{{.BS}}Program Files {{.E}}(x86{{.E}}){{.BS}}{{.O}}`,
|
`C:\Program Files (x86)\`: `{{.O}}C:{{.PB}}Program Files {{.E}}(x86{{.E}}){{.BS}}{{.O}}`,
|
||||||
`"C:\Program Files"`: `{{.O}}{{.DQ}}C:{{.BS}}Program Files{{.DQ}}{{.O}}`,
|
`"C:\Program Files"`: `{{.O}}{{.DQ}}C:{{.PB}}Program Files{{.DQ}}{{.O}}`,
|
||||||
}
|
}
|
||||||
|
|
||||||
for input, expected := range tests {
|
for input, expected := range tests {
|
||||||
@@ -440,7 +440,7 @@ func TestPowershellCommands(t *testing.T) {
|
|||||||
// to explorer, which will prompt user to pick editing program for the fzf-preview file
|
// to explorer, which will prompt user to pick editing program for the fzf-preview file
|
||||||
// the temp file contains: `cat "C:\test.txt"`
|
// the temp file contains: `cat "C:\test.txt"`
|
||||||
// TODO this should actually work
|
// TODO this should actually work
|
||||||
{give{`powershell -NoProfile -Command {f}`, ``, newItems(`cat "C:\test.txt"`)}, want{match: `^powershell -NoProfile -Command .*\fzf-preview-[0-9]{9}$`}},
|
{give{`powershell -NoProfile -Command {f}`, ``, newItems(`cat "C:\test.txt"`)}, want{match: `^powershell -NoProfile -Command .*\fzf-temp-[0-9]+$`}},
|
||||||
}
|
}
|
||||||
|
|
||||||
// to force powershell-style escaping we temporarily set environment variable that fzf honors
|
// to force powershell-style escaping we temporarily set environment variable that fzf honors
|
||||||
@@ -549,6 +549,89 @@ func TestExtractPassthroughs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWrapPassThrough(t *testing.T) {
|
||||||
|
for _, passThrough := range []string{
|
||||||
|
"\x1bPtmux;\x1b\x1b_Ga=d,d=A\x1b\x1b\\\x1b\\", // Already wrapped
|
||||||
|
"\x1bP0;1;0q#0;2;0;0;0#0~~@@vv@@~~@\x1b\\", // Sixel
|
||||||
|
"\x1b]1337;File=inline=1:AAAA\a", // iTerm2
|
||||||
|
} {
|
||||||
|
if got := wrapPassThrough(passThrough, true); got != passThrough {
|
||||||
|
t.Errorf("should have been left alone: %q -> %q", passThrough, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kitty := "\x1b_Ga=d,d=A\x1b\\"
|
||||||
|
if got := wrapPassThrough(kitty, false); got != kitty {
|
||||||
|
t.Errorf("should have been left alone outside of tmux: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ESC characters are doubled, and the trailing carriage return is kept
|
||||||
|
// outside of the wrapper
|
||||||
|
for _, test := range []struct{ input, want string }{
|
||||||
|
{kitty, "\x1bPtmux;\x1b\x1b_Ga=d,d=A\x1b\x1b\\\x1b\\"},
|
||||||
|
{kitty + "\r", "\x1bPtmux;\x1b\x1b_Ga=d,d=A\x1b\x1b\\\x1b\\\r"},
|
||||||
|
{"\x1b_Gm=1;\x1bAAA=\x1b\\", "\x1bPtmux;\x1b\x1b_Gm=1;\x1b\x1bAAA=\x1b\x1b\\\x1b\\"},
|
||||||
|
} {
|
||||||
|
if got := wrapPassThrough(test.input, true); got != test.want {
|
||||||
|
t.Errorf("expected %q, got %q", test.want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsImagePassThrough(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
given string
|
||||||
|
image bool
|
||||||
|
}{
|
||||||
|
// Kitty
|
||||||
|
{"\x1b_Ga=T,f=32,s=1258,v=1295,c=74,r=35,m=1\x1b\\", true},
|
||||||
|
{"\x1b_Ga=p,i=1\x1b\\", true},
|
||||||
|
// The action key is not always first, and a put carries no payload
|
||||||
|
{"\x1b_Gi=1,a=p\x1b\\", true},
|
||||||
|
{"\x1b_Ga=p\x1b\\", true},
|
||||||
|
{"\x1b_Ga=T,f=100\x1b\\\r", true},
|
||||||
|
{"\x1b_Gi=1,a=d\x1b\\", false},
|
||||||
|
{"\x1b_Ga=d,d=A\x1b\\", false}, // 'kitten icat --clear'
|
||||||
|
{"\x1b_Ga=q,i=1\x1b\\", false}, // query
|
||||||
|
{"\x1b_Gi=1,f=100\x1b\\", false}, // transmit only, the default action
|
||||||
|
{"\x1b_Gm=1;AAAA\x1b\\", false}, // continuation chunk
|
||||||
|
{"\x1b_Ga=f,i=1;AAAA\x1b\\", false}, // animation frame
|
||||||
|
{"\x1b_Ga=T,U=1,f=32;AAAA\x1b\\", true}, // unicode placeholder
|
||||||
|
// Wrapped in the tmux passthrough sequence
|
||||||
|
{"\x1bPtmux;\x1b\x1b_Ga=T,f=100\x1b\x1b\\\x1b\\", true},
|
||||||
|
{"\x1bPtmux;\x1b\x1b_Ga=d,d=A\x1b\x1b\\\x1b\\", false},
|
||||||
|
{"\x1bPtmux;\x1b\x1b_Gi=1,a=p\x1b\x1b\\\x1b\\", true},
|
||||||
|
// iTerm2
|
||||||
|
{"\x1b]1337;File=inline=1:AAAA\a", true},
|
||||||
|
{"\x1b]1337;MultipartFile=inline=1\a", true},
|
||||||
|
{"\x1b]1337;SetUserVar=foo=YmFy\a", false},
|
||||||
|
{"\x1b]1337;CurrentDir=/tmp\a", false},
|
||||||
|
// Sixel
|
||||||
|
{"\x1bP0;1;0q#0;2;0;0;0#0~~@@vv@@~~@\x1b\\", true},
|
||||||
|
{"\x1bPq#0~~\x1b\\", true},
|
||||||
|
{"\x1bPtmux;\x1b\x1bP0;1;0q#0~~\x1b\x1b\\\x1b\\", true},
|
||||||
|
{"", false},
|
||||||
|
} {
|
||||||
|
if actual := isImagePassThrough(tc.given); actual != tc.image {
|
||||||
|
t.Errorf("expected %v for %q, got %v", tc.image, tc.given, actual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if containsImage([]string{"foo", "bar"}) {
|
||||||
|
t.Error("plain text carries no image")
|
||||||
|
}
|
||||||
|
if !containsImage([]string{"foo", "bar\x1b_Ga=T,f=100\x1b\\baz"}) {
|
||||||
|
t.Error("failed to find an image in a line")
|
||||||
|
}
|
||||||
|
if containsImage([]string{"foo\x1b_Ga=d,d=A\x1b\\"}) {
|
||||||
|
t.Error("a delete command is not an image")
|
||||||
|
}
|
||||||
|
// The image is not the first passthrough on the line
|
||||||
|
if !containsImage([]string{"\x1b_Ga=d,d=A\x1b\\text\x1b_Ga=T,f=100;AAAA\x1b\\"}) {
|
||||||
|
t.Error("failed to look past an earlier passthrough")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* utilities section */
|
/* utilities section */
|
||||||
|
|
||||||
// Item represents one line in fzf UI. Usually it is relative path to files and folders.
|
// Item represents one line in fzf UI. Usually it is relative path to files and folders.
|
||||||
@@ -721,7 +804,7 @@ func TestWordWrapAnsiLine(t *testing.T) {
|
|||||||
t.Errorf("ANSI: %q", result)
|
t.Errorf("ANSI: %q", result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Long word (no space) — no break, let character wrapping handle it
|
// Long word (no space) - no break, let character wrapping handle it
|
||||||
result = term.wordWrapAnsiLine("abcdefghij", 5, 2)
|
result = term.wordWrapAnsiLine("abcdefghij", 5, 2)
|
||||||
if len(result) != 1 || result[0] != "abcdefghij" {
|
if len(result) != 1 || result[0] != "abcdefghij" {
|
||||||
t.Errorf("Long word: %q", result)
|
t.Errorf("Long word: %q", result)
|
||||||
@@ -749,7 +832,7 @@ func TestWordWrapAnsiLine(t *testing.T) {
|
|||||||
|
|
||||||
// Tab handling: tab expands to tabstop-aligned width
|
// Tab handling: tab expands to tabstop-aligned width
|
||||||
term.tabstop = 8
|
term.tabstop = 8
|
||||||
// "\thi there" — tab at column 0 expands to 8, total "hi" starts at 8
|
// "\thi there" - tab at column 0 expands to 8, total "hi" starts at 8
|
||||||
// maxWidth=15: "\thi" = 10 wide, "there" = 5 wide, total 16 > 15, wrap at space
|
// maxWidth=15: "\thi" = 10 wide, "there" = 5 wide, total 16 > 15, wrap at space
|
||||||
result = term.wordWrapAnsiLine("\thi there", 15, 2)
|
result = term.wordWrapAnsiLine("\thi there", 15, 2)
|
||||||
if len(result) != 2 || result[0] != "\thi" || result[1] != "there" {
|
if len(result) != 2 || result[0] != "\thi" || result[1] != "there" {
|
||||||
@@ -768,3 +851,73 @@ func TestWordWrapAnsiLine(t *testing.T) {
|
|||||||
t.Errorf("Tab wrap: %q", result)
|
t.Errorf("Tab wrap: %q", result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSplitOnIND(t *testing.T) {
|
||||||
|
term := &Terminal{tabstop: 8}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
line string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
// Nothing to do, so the caller keeps the line as it read it
|
||||||
|
name: "no IND",
|
||||||
|
line: "foo\n",
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// chafa: CUB returns to the column the row started on
|
||||||
|
name: "rows at column 0",
|
||||||
|
line: "AAA\x1b[3D\x1bDBBB\x1b[3D\x1bDCCC\n",
|
||||||
|
want: []string{"AAA\x1b[3D\n", "BBB\x1b[3D\n", "CCC\n"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 'printf " "; chafa ...'
|
||||||
|
name: "indented rows",
|
||||||
|
line: " AAA\x1b[3D\x1bDBBB\x1b[3D\x1bDCCC\n",
|
||||||
|
want: []string{" AAA\x1b[3D\n", " BBB\x1b[3D\n", " CCC\n"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tab indent expands to the tab stop",
|
||||||
|
line: "\tAAA\x1b[3D\x1bDBBB\x1b[3D\x1bDCCC\n",
|
||||||
|
want: []string{"\tAAA\x1b[3D\n", " BBB\x1b[3D\n", " CCC\n"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// IND on its own keeps the column
|
||||||
|
name: "bare IND",
|
||||||
|
line: "AB\x1bDCD\n",
|
||||||
|
want: []string{"AB\n", " CD\n"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CUB without a parameter moves back one",
|
||||||
|
line: "AB\x1b[D\x1bDCD\n",
|
||||||
|
want: []string{"AB\x1b[D\n", " CD\n"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CUB past the left edge is clamped",
|
||||||
|
line: "AB\x1b[9D\x1bDCD\n",
|
||||||
|
want: []string{"AB\x1b[9D\n", "CD\n"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SGR codes take no column",
|
||||||
|
line: "\x1b[31mAB\x1b[m\x1b[2D\x1bDCD\n",
|
||||||
|
want: []string{"\x1b[31mAB\x1b[m\x1b[2D\n", "CD\n"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pass-throughs take no column",
|
||||||
|
line: "\x1b_Ga=T,c=2,r=1\x1b\\AB\x1b[2D\x1bDCD\n",
|
||||||
|
want: []string{"\x1b_Ga=T,c=2,r=1\x1b\\AB\x1b[2D\n", "CD\n"},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
got := term.splitOnIND(tc.line)
|
||||||
|
if len(got) != len(tc.want) {
|
||||||
|
t.Errorf("%s: got %q, want %q", tc.name, got, tc.want)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for idx, line := range got {
|
||||||
|
if line != tc.want[idx] {
|
||||||
|
t.Errorf("%s: line %d: got %q, want %q", tc.name, idx, line, tc.want[idx])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
package fzf
|
package fzf
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -10,8 +11,12 @@ import (
|
|||||||
"golang.org/x/sys/unix"
|
"golang.org/x/sys/unix"
|
||||||
)
|
)
|
||||||
|
|
||||||
func notifyOnResize(resizeChan chan<- os.Signal) {
|
func notifyOnResize(ctx context.Context, resizeChan chan<- os.Signal) {
|
||||||
signal.Notify(resizeChan, syscall.SIGWINCH)
|
signal.Notify(resizeChan, syscall.SIGWINCH)
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
signal.Stop(resizeChan)
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
func notifyStop(p *os.Process) {
|
func notifyStop(p *os.Process) {
|
||||||
|
|||||||
+49
-2
@@ -3,11 +3,58 @@
|
|||||||
package fzf
|
package fzf
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"os"
|
"os"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
)
|
)
|
||||||
|
|
||||||
func notifyOnResize(resizeChan chan<- os.Signal) {
|
const resizePollInterval = 100 * time.Millisecond
|
||||||
// TODO
|
|
||||||
|
type resizeSignal struct{}
|
||||||
|
|
||||||
|
func (resizeSignal) String() string { return "resize" }
|
||||||
|
func (resizeSignal) Signal() {}
|
||||||
|
|
||||||
|
// Windows has no SIGWINCH, so poll the console screen buffer for window
|
||||||
|
// size changes instead.
|
||||||
|
func notifyOnResize(ctx context.Context, resizeChan chan<- os.Signal) {
|
||||||
|
consoleOut, err := syscall.Open("CONOUT$", syscall.O_RDWR, 0)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var info windows.ConsoleScreenBufferInfo
|
||||||
|
if windows.GetConsoleScreenBufferInfo(windows.Handle(consoleOut), &info) != nil {
|
||||||
|
syscall.Close(consoleOut)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
last := info.Window
|
||||||
|
go func() {
|
||||||
|
defer syscall.Close(consoleOut)
|
||||||
|
ticker := time.NewTicker(resizePollInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
if windows.GetConsoleScreenBufferInfo(windows.Handle(consoleOut), &info) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
current := info.Window
|
||||||
|
if current.Right-current.Left != last.Right-last.Left ||
|
||||||
|
current.Bottom-current.Top != last.Bottom-last.Top {
|
||||||
|
last = current
|
||||||
|
select {
|
||||||
|
case resizeChan <- resizeSignal{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
func notifyStop(p *os.Process) {
|
func notifyStop(p *os.Process) {
|
||||||
|
|||||||
+217
-21
@@ -1,39 +1,235 @@
|
|||||||
package fzf
|
package fzf
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/junegunn/fzf/src/tui"
|
"github.com/junegunn/fzf/src/tui"
|
||||||
)
|
)
|
||||||
|
|
||||||
func runTmux(args []string, opts *Options) (int, error) {
|
// Returns the size of the current window if the tmux server supports
|
||||||
// Prepare arguments
|
// floating panes (tmux 3.7 or above)
|
||||||
fzf, rest := args[0], args[1:]
|
func tmuxFloatingPaneInfo() (int, int, bool) {
|
||||||
args = []string{"--bind=ctrl-z:ignore"}
|
// TMUX_PANE is not set when fzf is not started from within a pane
|
||||||
if !opts.Tmux.border && (opts.BorderShape == tui.BorderUndefined || opts.BorderShape == tui.BorderLine) {
|
// (e.g. 'bind-key 0 run-shell "fzf --popup"'). Do not use a floating
|
||||||
// We append --border option at the end, because `--style=full:STYLE`
|
// pane there; a blocking run-shell suspends key processing for the
|
||||||
// may have changed the default border style.
|
// client until the command exits, so fzf in a floating pane would
|
||||||
if tui.DefaultBorderShape == tui.BorderRounded {
|
// never receive a key and the client would appear frozen until the
|
||||||
rest = append(rest, "--border=rounded")
|
// process is killed externally. A popup is unaffected; its input is
|
||||||
} else {
|
// handled at the client overlay level, bypassing the suspended path.
|
||||||
rest = append(rest, "--border=sharp")
|
// The right approach for such bindings is 'run-shell -b', which does
|
||||||
|
// not suspend key processing, as discussed in:
|
||||||
|
// https://github.com/tmux/tmux/issues/5384
|
||||||
|
target := os.Getenv("TMUX_PANE")
|
||||||
|
if target == "" {
|
||||||
|
return 0, 0, false
|
||||||
}
|
}
|
||||||
|
// A single invocation for both checks. Cannot rely on the exit status;
|
||||||
|
// tmux versions before 3.7 exit normally with empty output for an
|
||||||
|
// unknown command name, so check the output instead.
|
||||||
|
out, err := exec.Command("tmux", "display-message", "-p", "-t", target,
|
||||||
|
"#{window_width} #{window_height}", ";", "list-commands", "new-pane").Output()
|
||||||
|
if err != nil || !strings.Contains(string(out), "new-pane") {
|
||||||
|
return 0, 0, false
|
||||||
}
|
}
|
||||||
if opts.Tmux.border && opts.Margin == defaultMargin() {
|
var width, height int
|
||||||
args = append(args, "--margin=0,1")
|
if _, err := fmt.Sscanf(string(out), "%d %d", &width, &height); err != nil {
|
||||||
|
return 0, 0, false
|
||||||
}
|
}
|
||||||
argStr := escapeSingleQuote(fzf)
|
// The window is too small to fit a floating pane of the minimum size
|
||||||
for _, arg := range append(args, rest...) {
|
if width < 3 || height < 3 {
|
||||||
argStr += " " + escapeSingleQuote(arg)
|
return 0, 0, false
|
||||||
}
|
}
|
||||||
argStr += ` --no-tmux --no-height`
|
return width, height, true
|
||||||
|
}
|
||||||
|
|
||||||
// Get current directory
|
// tmux ends a command at an argument ending in ';', so a trailing one is
|
||||||
dir, err := os.Getwd()
|
// escaped. Elsewhere it is not special
|
||||||
if err != nil {
|
func escapeTmuxSeparator(str string) string {
|
||||||
dir = "."
|
if strings.HasSuffix(str, ";") {
|
||||||
|
return str[:len(str)-1] + `\;`
|
||||||
}
|
}
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
|
||||||
|
// '#[...]' is still processed when a substituted value is drawn, so '#' is
|
||||||
|
// doubled. '#{...}' is already literal
|
||||||
|
func escapeTmuxFormat(str string) string {
|
||||||
|
return escapeTmuxSeparator(strings.ReplaceAll(str, "#", "##"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The option pane-border-format reads back, and the pane it is set on
|
||||||
|
const (
|
||||||
|
tmuxBorderLabelOption = "@fzf-border-label"
|
||||||
|
tmuxBorderLabelEnv = internalEnvPrefix + "TMUX_LABEL_PANE"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Errors are ignored. The pane may be gone
|
||||||
|
func setTmuxBorderLabel(pane string, label string) {
|
||||||
|
// Strip ANSI sequences. tmux would draw the escape's remainder as text
|
||||||
|
label, _, _ = extractColor(label, nil, nil)
|
||||||
|
exec.Command("tmux", "set-option", "-p", "-t", pane,
|
||||||
|
tmuxBorderLabelOption, escapeTmuxFormat(label)).Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert sizeSpec to the number of cells, clamped between the minimum
|
||||||
|
// footprint of 3, including the border, and the window size
|
||||||
|
func tmuxDim(spec sizeSpec, window int) int {
|
||||||
|
dim := int(spec.size)
|
||||||
|
if spec.percent {
|
||||||
|
dim = window * dim / 100
|
||||||
|
}
|
||||||
|
return max(3, min(dim, window))
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTmuxFloatingPane(argStr string, dir string, windowWidth int, windowHeight int, opts *Options) (int, error) {
|
||||||
|
// Unlike display-popup, the size of a floating pane does not account for
|
||||||
|
// the border around it, and the position is that of the content area. To
|
||||||
|
// stay consistent with popups, treat the requested size as the total
|
||||||
|
// footprint including the border.
|
||||||
|
width := tmuxDim(opts.Tmux.width, windowWidth)
|
||||||
|
height := tmuxDim(opts.Tmux.height, windowHeight)
|
||||||
|
x := (windowWidth-width)/2 + 1
|
||||||
|
y := (windowHeight-height)/2 + 1
|
||||||
|
switch opts.Tmux.position {
|
||||||
|
case posUp:
|
||||||
|
y = 1
|
||||||
|
case posDown:
|
||||||
|
y = windowHeight - height + 1
|
||||||
|
case posLeft:
|
||||||
|
x = 1
|
||||||
|
case posRight:
|
||||||
|
x = windowWidth - width + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return runProxy(argStr, func(temp string, needBash bool) (*exec.Cmd, error) {
|
||||||
|
sh, err := sh(needBash)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Unlike display-popup, new-pane does not block until the command
|
||||||
|
// finishes, and it does not propagate the exit status. So we block on
|
||||||
|
// a wait-for channel that the pane signals on completion, and pass
|
||||||
|
// the exit status through a temporary file. A watchdog process
|
||||||
|
// signals the same channel if the pane is closed abnormally
|
||||||
|
// (e.g. kill-pane), in which case the file is not written.
|
||||||
|
//
|
||||||
|
// has-session is the liveness check because it fails when the target
|
||||||
|
// pane is gone, while display-message succeeds even for a dead pane.
|
||||||
|
signal := escapeSingleQuote("fzf-" + filepath.Base(temp))
|
||||||
|
|
||||||
|
// Pre-create the exit status file so that another user on a shared
|
||||||
|
// TMPDIR cannot plant a file or a symbolic link at the predictable
|
||||||
|
// path while the pane is running
|
||||||
|
codeFile := temp + ".code"
|
||||||
|
f, err := os.OpenFile(codeFile, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
code := escapeSingleQuote(codeFile)
|
||||||
|
|
||||||
|
// Pane options are set by the pane itself before running fzf, so
|
||||||
|
// that they are in place no matter how quickly the command exits.
|
||||||
|
// The target must be explicit; without it, the commands would
|
||||||
|
// resolve to the active pane of the session's current window,
|
||||||
|
// which is not necessarily the pane running them.
|
||||||
|
//
|
||||||
|
// The pane should always close on exit like a popup, even when
|
||||||
|
// remain-on-exit is on.
|
||||||
|
setup := `tmux set-option -p -t "$TMUX_PANE" remain-on-exit off 2> /dev/null; `
|
||||||
|
|
||||||
|
// --border-label lives in a pane-scoped user option that
|
||||||
|
// pane-border-format reads back, so a program in the pane cannot
|
||||||
|
// replace it by setting the title. Set even when empty, so
|
||||||
|
// change-border-label can fill it in and the default pane status is
|
||||||
|
// not shown. pane-border-status is a window option until the next
|
||||||
|
// tmux release, so it is left alone.
|
||||||
|
// https://github.com/tmux/tmux/commit/7a18fa281db3
|
||||||
|
// --border-label-pos is ignored. With an explicit --border fzf draws
|
||||||
|
// the label itself, but the native border cannot be removed.
|
||||||
|
ownsLabel := noBorderSpecified(opts)
|
||||||
|
label := ""
|
||||||
|
if ownsLabel {
|
||||||
|
// Strip ANSI sequences fzf would otherwise render itself
|
||||||
|
label, _, _ = extractColor(opts.BorderLabel.label, nil, nil)
|
||||||
|
}
|
||||||
|
setup += fmt.Sprintf(`tmux set-option -p -t "$TMUX_PANE" %s %s 2> /dev/null; `,
|
||||||
|
tmuxBorderLabelOption, escapeSingleQuote(escapeTmuxFormat(label)))
|
||||||
|
setup += fmt.Sprintf(`tmux set-option -p -t "$TMUX_PANE" pane-border-format %s 2> /dev/null; `,
|
||||||
|
escapeSingleQuote("#{"+tmuxBorderLabelOption+"}"))
|
||||||
|
if ownsLabel {
|
||||||
|
setup += fmt.Sprintf(`export %s="$TMUX_PANE"; `, tmuxBorderLabelEnv)
|
||||||
|
}
|
||||||
|
paneCmd := fmt.Sprintf("%s%s %s; echo $? > %s; tmux wait-for -S %s",
|
||||||
|
setup, escapeSingleQuote(sh), escapeSingleQuote(temp), code, signal)
|
||||||
|
// Unzoom the window first; creating a floating pane over a zoomed
|
||||||
|
// window crashes the tmux server on 3.7b, and newer versions of
|
||||||
|
// tmux unzoom the window anyway.
|
||||||
|
target := os.Getenv("TMUX_PANE")
|
||||||
|
newPane := fmt.Sprintf(
|
||||||
|
"tmux if -F -t %s '#{window_zoomed_flag}' %s ';' new-pane -P -F '#{pane_id}' -t %s -c %s -x %d -y %d -X %d -Y %d %s -c %s",
|
||||||
|
escapeSingleQuote(target), escapeSingleQuote("resize-pane -Z -t "+target),
|
||||||
|
escapeSingleQuote(target), escapeSingleQuote(dir), width-2, height-2, x, y,
|
||||||
|
escapeSingleQuote(sh), escapeSingleQuote(paneCmd))
|
||||||
|
|
||||||
|
// The pane is killed when the proxy process is interrupted or hung up,
|
||||||
|
// like a popup dying with its client. wait-for runs in the background
|
||||||
|
// and is awaited with the interruptible wait builtin so that the trap
|
||||||
|
// can fire while blocked. The trap is installed before creating the
|
||||||
|
// pane; a signal received during creation is deferred until the
|
||||||
|
// command substitution completes, and the pane is killed right after.
|
||||||
|
// An interrupted wait does not reap the waiter, so it is killed
|
||||||
|
// along with the watchdog.
|
||||||
|
script := fmt.Sprintf(`trap '[ -n "$id" ] && tmux kill-pane -t "$id" 2> /dev/null' INT TERM HUP
|
||||||
|
id=$(%s) || { status=$?; rm -f %s; exit "$status"; }
|
||||||
|
{ while tmux has-session -t "$id" 2> /dev/null; do sleep 1; done; tmux wait-for -S %s; } &
|
||||||
|
watchdog=$!
|
||||||
|
tmux wait-for %s &
|
||||||
|
waiter=$!
|
||||||
|
wait "$waiter"
|
||||||
|
kill "$watchdog" "$waiter" 2> /dev/null
|
||||||
|
wait 2> /dev/null
|
||||||
|
if [ -s %s ]; then code=$(cat %s); else code=130; fi
|
||||||
|
rm -f %s
|
||||||
|
exit "$code"`, newPane, code, signal, signal, code, code, code)
|
||||||
|
return exec.Command(sh, "-c", script), nil
|
||||||
|
}, opts, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether no box border was asked for. 'none' and 'line' count as none.
|
||||||
|
// fzf draws no box for either
|
||||||
|
func noBorderSpecified(opts *Options) bool {
|
||||||
|
return opts.BorderShape == tui.BorderUndefined ||
|
||||||
|
opts.BorderShape == tui.BorderLine || opts.BorderShape == tui.BorderNone
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether to use the multiplexer's native border for the floating pane. Its
|
||||||
|
// native border is the handle that makes the pane movable and resizable with
|
||||||
|
// the mouse, so it is the default; 'border-native' forces it. It is not used
|
||||||
|
// when a border style is explicitly specified with --border, so that the
|
||||||
|
// fzf-drawn border is the only one shown.
|
||||||
|
func nativeBorder(opts *Options) bool {
|
||||||
|
return opts.Tmux.border || noBorderSpecified(opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTmux(args []string, opts *Options) (int, error) {
|
||||||
|
// On tmux 3.7 or above, fzf runs in a floating pane instead of a popup.
|
||||||
|
// When the native border is not used (an explicit --border style), a
|
||||||
|
// popup is used instead so that the fzf-drawn border is the only border
|
||||||
|
// shown; the native border of a tmux floating pane cannot be removed.
|
||||||
|
if nativeBorder(opts) {
|
||||||
|
if windowWidth, windowHeight, ok := tmuxFloatingPaneInfo(); ok {
|
||||||
|
opts.Tmux.border = true
|
||||||
|
argStr, dir := popupArgStr(args, opts)
|
||||||
|
return runTmuxFloatingPane(argStr, dir, windowWidth, windowHeight, opts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
argStr, dir := popupArgStr(args, opts)
|
||||||
|
|
||||||
// Set tmux options for popup placement
|
// Set tmux options for popup placement
|
||||||
// C Both The centre of the terminal
|
// C Both The centre of the terminal
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package fzf
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestEscapeTmuxFormat(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
given string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"", ""},
|
||||||
|
{" fzf ", " fzf "},
|
||||||
|
{"100%", "100%"},
|
||||||
|
// '#[' would be taken as a style directive when the label is drawn
|
||||||
|
{"#[fg=red]x", "##[fg=red]x"},
|
||||||
|
// '#{' is already literal in a substituted value, but doubling it
|
||||||
|
// keeps a label that fzf renders itself looking the same
|
||||||
|
{"#{pane_id}", "##{pane_id}"},
|
||||||
|
{" C# notes #S ", " C## notes ##S "},
|
||||||
|
{";", `\;`},
|
||||||
|
{"; rm", "; rm"},
|
||||||
|
{"C#;", `C##\;`},
|
||||||
|
} {
|
||||||
|
if actual := escapeTmuxFormat(tc.given); actual != tc.expected {
|
||||||
|
t.Errorf("expected %q, got %q", tc.expected, actual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEscapeTmuxSeparator(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
given string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"", ""},
|
||||||
|
{" fzf ", " fzf "},
|
||||||
|
{"#", "#"},
|
||||||
|
{"#{pane_id}", "#{pane_id}"},
|
||||||
|
{"100%", "100%"},
|
||||||
|
// tmux drops a trailing ';' and everything after it, so only that
|
||||||
|
// one is escaped
|
||||||
|
{";", `\;`},
|
||||||
|
{"abc;", `abc\;`},
|
||||||
|
{";;", `;\;`},
|
||||||
|
{`foo\;`, `foo\\;`},
|
||||||
|
{"; rm", "; rm"},
|
||||||
|
{" ; ", " ; "},
|
||||||
|
{"a;b", "a;b"},
|
||||||
|
} {
|
||||||
|
if actual := escapeTmuxSeparator(tc.given); actual != tc.expected {
|
||||||
|
t.Errorf("expected %q, got %q", tc.expected, actual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-4
@@ -161,7 +161,7 @@ func awkTokenizer(input string) ([]string, int) {
|
|||||||
end := 0
|
end := 0
|
||||||
for idx := 0; idx < len(input); idx++ {
|
for idx := 0; idx < len(input); idx++ {
|
||||||
r := input[idx]
|
r := input[idx]
|
||||||
white := r == 9 || r == 32
|
white := r == 9 || r == 32 || r == 10
|
||||||
switch state {
|
switch state {
|
||||||
case awkNil:
|
case awkNil:
|
||||||
if white {
|
if white {
|
||||||
@@ -218,11 +218,12 @@ func Tokenize(text string, delimiter Delimiter) []Token {
|
|||||||
return withPrefixLengths(tokens, 0)
|
return withPrefixLengths(tokens, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// StripLastDelimiter removes the trailing delimiter and whitespaces
|
// StripLastDelimiter removes the trailing delimiter
|
||||||
func StripLastDelimiter(str string, delimiter Delimiter) string {
|
func StripLastDelimiter(str string, delimiter Delimiter) string {
|
||||||
if delimiter.str != nil {
|
if delimiter.str != nil {
|
||||||
str = strings.TrimSuffix(str, *delimiter.str)
|
return strings.TrimSuffix(str, *delimiter.str)
|
||||||
} else if delimiter.regex != nil {
|
}
|
||||||
|
if delimiter.regex != nil {
|
||||||
locs := delimiter.regex.FindAllStringIndex(str, -1)
|
locs := delimiter.regex.FindAllStringIndex(str, -1)
|
||||||
if len(locs) > 0 {
|
if len(locs) > 0 {
|
||||||
lastLoc := locs[len(locs)-1]
|
lastLoc := locs[len(locs)-1]
|
||||||
@@ -230,6 +231,7 @@ func StripLastDelimiter(str string, delimiter Delimiter) string {
|
|||||||
str = str[:lastLoc[0]]
|
str = str[:lastLoc[0]]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return str
|
||||||
}
|
}
|
||||||
return strings.TrimRightFunc(str, unicode.IsSpace)
|
return strings.TrimRightFunc(str, unicode.IsSpace)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,9 +56,9 @@ func TestParseRange(t *testing.T) {
|
|||||||
|
|
||||||
func TestTokenize(t *testing.T) {
|
func TestTokenize(t *testing.T) {
|
||||||
// AWK-style
|
// AWK-style
|
||||||
input := " abc: def: ghi "
|
input := " abc: \n\t def: ghi "
|
||||||
tokens := Tokenize(input, Delimiter{})
|
tokens := Tokenize(input, Delimiter{})
|
||||||
if tokens[0].text.ToString() != "abc: " || tokens[0].prefixLength != 2 {
|
if tokens[0].text.ToString() != "abc: \n\t " || tokens[0].prefixLength != 2 {
|
||||||
t.Errorf("%s", tokens)
|
t.Errorf("%s", tokens)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,9 +71,9 @@ func TestTokenize(t *testing.T) {
|
|||||||
// With delimiter regex
|
// With delimiter regex
|
||||||
tokens = Tokenize(input, delimiterRegexp("\\s+"))
|
tokens = Tokenize(input, delimiterRegexp("\\s+"))
|
||||||
if tokens[0].text.ToString() != " " || tokens[0].prefixLength != 0 ||
|
if tokens[0].text.ToString() != " " || tokens[0].prefixLength != 0 ||
|
||||||
tokens[1].text.ToString() != "abc: " || tokens[1].prefixLength != 2 ||
|
tokens[1].text.ToString() != "abc: \n\t " || tokens[1].prefixLength != 2 ||
|
||||||
tokens[2].text.ToString() != "def: " || tokens[2].prefixLength != 8 ||
|
tokens[2].text.ToString() != "def: " || tokens[2].prefixLength != 10 ||
|
||||||
tokens[3].text.ToString() != "ghi " || tokens[3].prefixLength != 14 {
|
tokens[3].text.ToString() != "ghi " || tokens[3].prefixLength != 16 {
|
||||||
t.Errorf("%s", tokens)
|
t.Errorf("%s", tokens)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-18
@@ -133,22 +133,22 @@ func _() {
|
|||||||
_ = x[CtrlAltShiftDelete-122]
|
_ = x[CtrlAltShiftDelete-122]
|
||||||
_ = x[CtrlAltShiftPageUp-123]
|
_ = x[CtrlAltShiftPageUp-123]
|
||||||
_ = x[CtrlAltShiftPageDown-124]
|
_ = x[CtrlAltShiftPageDown-124]
|
||||||
_ = x[Invalid-125]
|
_ = x[Mouse-125]
|
||||||
_ = x[Fatal-126]
|
_ = x[DoubleClick-126]
|
||||||
_ = x[BracketedPasteBegin-127]
|
_ = x[LeftClick-127]
|
||||||
_ = x[BracketedPasteEnd-128]
|
_ = x[RightClick-128]
|
||||||
_ = x[Mouse-129]
|
_ = x[SLeftClick-129]
|
||||||
_ = x[DoubleClick-130]
|
_ = x[SRightClick-130]
|
||||||
_ = x[LeftClick-131]
|
_ = x[ScrollUp-131]
|
||||||
_ = x[RightClick-132]
|
_ = x[ScrollDown-132]
|
||||||
_ = x[SLeftClick-133]
|
_ = x[SScrollUp-133]
|
||||||
_ = x[SRightClick-134]
|
_ = x[SScrollDown-134]
|
||||||
_ = x[ScrollUp-135]
|
_ = x[PreviewScrollUp-135]
|
||||||
_ = x[ScrollDown-136]
|
_ = x[PreviewScrollDown-136]
|
||||||
_ = x[SScrollUp-137]
|
_ = x[Invalid-137]
|
||||||
_ = x[SScrollDown-138]
|
_ = x[Fatal-138]
|
||||||
_ = x[PreviewScrollUp-139]
|
_ = x[BracketedPasteBegin-139]
|
||||||
_ = x[PreviewScrollDown-140]
|
_ = x[BracketedPasteEnd-140]
|
||||||
_ = x[Resize-141]
|
_ = x[Resize-141]
|
||||||
_ = x[Change-142]
|
_ = x[Change-142]
|
||||||
_ = x[BackwardEOF-143]
|
_ = x[BackwardEOF-143]
|
||||||
@@ -163,11 +163,13 @@ func _() {
|
|||||||
_ = x[ClickHeader-152]
|
_ = x[ClickHeader-152]
|
||||||
_ = x[ClickFooter-153]
|
_ = x[ClickFooter-153]
|
||||||
_ = x[Multi-154]
|
_ = x[Multi-154]
|
||||||
|
_ = x[Every-155]
|
||||||
|
_ = x[ResultFinal-156]
|
||||||
}
|
}
|
||||||
|
|
||||||
const _EventType_name = "RuneCtrlACtrlBCtrlCCtrlDCtrlECtrlFCtrlGCtrlHTabCtrlJCtrlKCtrlLEnterCtrlNCtrlOCtrlPCtrlQCtrlRCtrlSCtrlTCtrlUCtrlVCtrlWCtrlXCtrlYCtrlZEscCtrlSpaceCtrlBackSlashCtrlRightBracketCtrlCaretCtrlSlashShiftTabBackspaceDeletePageUpPageDownUpDownLeftRightHomeEndInsertShiftUpShiftDownShiftLeftShiftRightShiftDeleteShiftHomeShiftEndShiftPageUpShiftPageDownF1F2F3F4F5F6F7F8F9F10F11F12AltBackspaceAltUpAltDownAltLeftAltRightAltDeleteAltHomeAltEndAltPageUpAltPageDownAltShiftUpAltShiftDownAltShiftLeftAltShiftRightAltShiftDeleteAltShiftHomeAltShiftEndAltShiftPageUpAltShiftPageDownCtrlUpCtrlDownCtrlLeftCtrlRightCtrlHomeCtrlEndCtrlBackspaceCtrlDeleteCtrlPageUpCtrlPageDownAltCtrlAltCtrlAltUpCtrlAltDownCtrlAltLeftCtrlAltRightCtrlAltHomeCtrlAltEndCtrlAltBackspaceCtrlAltDeleteCtrlAltPageUpCtrlAltPageDownCtrlShiftUpCtrlShiftDownCtrlShiftLeftCtrlShiftRightCtrlShiftHomeCtrlShiftEndCtrlShiftDeleteCtrlShiftPageUpCtrlShiftPageDownCtrlAltShiftUpCtrlAltShiftDownCtrlAltShiftLeftCtrlAltShiftRightCtrlAltShiftHomeCtrlAltShiftEndCtrlAltShiftDeleteCtrlAltShiftPageUpCtrlAltShiftPageDownInvalidFatalBracketedPasteBeginBracketedPasteEndMouseDoubleClickLeftClickRightClickSLeftClickSRightClickScrollUpScrollDownSScrollUpSScrollDownPreviewScrollUpPreviewScrollDownResizeChangeBackwardEOFStartLoadFocusOneZeroResultJumpJumpCancelClickHeaderClickFooterMulti"
|
const _EventType_name = "RuneCtrlACtrlBCtrlCCtrlDCtrlECtrlFCtrlGCtrlHTabCtrlJCtrlKCtrlLEnterCtrlNCtrlOCtrlPCtrlQCtrlRCtrlSCtrlTCtrlUCtrlVCtrlWCtrlXCtrlYCtrlZEscCtrlSpaceCtrlBackSlashCtrlRightBracketCtrlCaretCtrlSlashShiftTabBackspaceDeletePageUpPageDownUpDownLeftRightHomeEndInsertShiftUpShiftDownShiftLeftShiftRightShiftDeleteShiftHomeShiftEndShiftPageUpShiftPageDownF1F2F3F4F5F6F7F8F9F10F11F12AltBackspaceAltUpAltDownAltLeftAltRightAltDeleteAltHomeAltEndAltPageUpAltPageDownAltShiftUpAltShiftDownAltShiftLeftAltShiftRightAltShiftDeleteAltShiftHomeAltShiftEndAltShiftPageUpAltShiftPageDownCtrlUpCtrlDownCtrlLeftCtrlRightCtrlHomeCtrlEndCtrlBackspaceCtrlDeleteCtrlPageUpCtrlPageDownAltCtrlAltCtrlAltUpCtrlAltDownCtrlAltLeftCtrlAltRightCtrlAltHomeCtrlAltEndCtrlAltBackspaceCtrlAltDeleteCtrlAltPageUpCtrlAltPageDownCtrlShiftUpCtrlShiftDownCtrlShiftLeftCtrlShiftRightCtrlShiftHomeCtrlShiftEndCtrlShiftDeleteCtrlShiftPageUpCtrlShiftPageDownCtrlAltShiftUpCtrlAltShiftDownCtrlAltShiftLeftCtrlAltShiftRightCtrlAltShiftHomeCtrlAltShiftEndCtrlAltShiftDeleteCtrlAltShiftPageUpCtrlAltShiftPageDownMouseDoubleClickLeftClickRightClickSLeftClickSRightClickScrollUpScrollDownSScrollUpSScrollDownPreviewScrollUpPreviewScrollDownInvalidFatalBracketedPasteBeginBracketedPasteEndResizeChangeBackwardEOFStartLoadFocusOneZeroResultJumpJumpCancelClickHeaderClickFooterMultiEveryResultFinal"
|
||||||
|
|
||||||
var _EventType_index = [...]uint16{0, 4, 9, 14, 19, 24, 29, 34, 39, 44, 47, 52, 57, 62, 67, 72, 77, 82, 87, 92, 97, 102, 107, 112, 117, 122, 127, 132, 135, 144, 157, 173, 182, 191, 199, 208, 214, 220, 228, 230, 234, 238, 243, 247, 250, 256, 263, 272, 281, 291, 302, 311, 319, 330, 343, 345, 347, 349, 351, 353, 355, 357, 359, 361, 364, 367, 370, 382, 387, 394, 401, 409, 418, 425, 431, 440, 451, 461, 473, 485, 498, 512, 524, 535, 549, 565, 571, 579, 587, 596, 604, 611, 624, 634, 644, 656, 659, 666, 675, 686, 697, 709, 720, 730, 746, 759, 772, 787, 798, 811, 824, 838, 851, 863, 878, 893, 910, 924, 940, 956, 973, 989, 1004, 1022, 1040, 1060, 1067, 1072, 1091, 1108, 1113, 1124, 1133, 1143, 1153, 1164, 1172, 1182, 1191, 1202, 1217, 1234, 1240, 1246, 1257, 1262, 1266, 1271, 1274, 1278, 1284, 1288, 1298, 1309, 1320, 1325}
|
var _EventType_index = [...]uint16{0, 4, 9, 14, 19, 24, 29, 34, 39, 44, 47, 52, 57, 62, 67, 72, 77, 82, 87, 92, 97, 102, 107, 112, 117, 122, 127, 132, 135, 144, 157, 173, 182, 191, 199, 208, 214, 220, 228, 230, 234, 238, 243, 247, 250, 256, 263, 272, 281, 291, 302, 311, 319, 330, 343, 345, 347, 349, 351, 353, 355, 357, 359, 361, 364, 367, 370, 382, 387, 394, 401, 409, 418, 425, 431, 440, 451, 461, 473, 485, 498, 512, 524, 535, 549, 565, 571, 579, 587, 596, 604, 611, 624, 634, 644, 656, 659, 666, 675, 686, 697, 709, 720, 730, 746, 759, 772, 787, 798, 811, 824, 838, 851, 863, 878, 893, 910, 924, 940, 956, 973, 989, 1004, 1022, 1040, 1060, 1065, 1076, 1085, 1095, 1105, 1116, 1124, 1134, 1143, 1154, 1169, 1186, 1193, 1198, 1217, 1234, 1240, 1246, 1257, 1262, 1266, 1271, 1274, 1278, 1284, 1288, 1298, 1309, 1320, 1325, 1330, 1341}
|
||||||
|
|
||||||
func (i EventType) String() string {
|
func (i EventType) String() string {
|
||||||
if i < 0 || i >= EventType(len(_EventType_index)-1) {
|
if i < 0 || i >= EventType(len(_EventType_index)-1) {
|
||||||
|
|||||||
+251
-142
@@ -24,21 +24,51 @@ const (
|
|||||||
defaultEscDelay = 100
|
defaultEscDelay = 100
|
||||||
escPollInterval = 5
|
escPollInterval = 5
|
||||||
offsetPollTries = 10
|
offsetPollTries = 10
|
||||||
|
queryTimeout = 500 * time.Millisecond
|
||||||
maxInputBuffer = 1024 * 1024
|
maxInputBuffer = 1024 * 1024
|
||||||
maxSelectTries = 100
|
maxSelectTries = 100
|
||||||
)
|
)
|
||||||
|
|
||||||
const DefaultTtyDevice string = "/dev/tty"
|
const DefaultTtyDevice string = "/dev/tty"
|
||||||
|
|
||||||
var offsetRegexp = regexp.MustCompile("(.*?)\x00?\x1b\\[([0-9]+);([0-9]+)R")
|
var offsetRegexp = regexp.MustCompile("\x00?\x1b\\[([0-9]+);([0-9]+)R")
|
||||||
var offsetRegexpBegin = regexp.MustCompile("^\x1b\\[[0-9]+;[0-9]+R")
|
var offsetRegexpBegin = regexp.MustCompile("^\x1b\\[[0-9]+;[0-9]+R")
|
||||||
|
|
||||||
|
// DECRPM reply to the DECRQM query for bracketed paste mode. Ps is 1 or 3 when
|
||||||
|
// the mode was already set, 2 or 4 when reset, 0 when the terminal does not
|
||||||
|
// recognize the mode.
|
||||||
|
var pasteModeRegexp = regexp.MustCompile("\x00?\x1b\\[\\?2004;([0-4])\\$y")
|
||||||
|
var pasteModeRegexpBegin = regexp.MustCompile("^\x1b\\[\\?2004;[0-4]\\$y")
|
||||||
|
|
||||||
|
// A report to ask the terminal for, and the reply to recognize it by.
|
||||||
|
type termQuery struct {
|
||||||
|
seq string
|
||||||
|
reply *regexp.Regexp
|
||||||
|
}
|
||||||
|
|
||||||
|
var offsetQuery = termQuery{"6n", offsetRegexp}
|
||||||
|
var pasteModeQuery = termQuery{"?2004$p", pasteModeRegexp}
|
||||||
|
|
||||||
|
// What we ask the terminal at startup, in the order the queries go out.
|
||||||
|
// A terminal answers them in that order, so the cursor position query is last
|
||||||
|
// and also ends the wait: every terminal fzf supports answers it, so once its
|
||||||
|
// reply arrives, a query still unanswered is one the terminal does not know
|
||||||
|
// rather than one we stopped waiting for too early.
|
||||||
|
//
|
||||||
|
// Terminals that don't support the paste mode query (DECRQM) might leave
|
||||||
|
// 'p' on the screen. To handle such cases, we query the position before and
|
||||||
|
// after it, compare them, and clean the artifact if they don't match.
|
||||||
|
//
|
||||||
|
// Reference: https://ansicode.eversources.app/en/sequence/decrqm
|
||||||
|
var startupQueries = []termQuery{offsetQuery, pasteModeQuery, offsetQuery}
|
||||||
|
|
||||||
func (r *LightRenderer) Bell() {
|
func (r *LightRenderer) Bell() {
|
||||||
r.flushRaw("\a")
|
r.flushRaw("\a")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *LightRenderer) PassThrough(str string) {
|
func (r *LightRenderer) PassThrough(str string) {
|
||||||
r.queued.WriteString("\x1b7" + str + "\x1b8")
|
r.queued.WriteString("\x1b7" + str + "\x1b8")
|
||||||
|
r.invalidateSGR()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *LightRenderer) stderr(str string) {
|
func (r *LightRenderer) stderr(str string) {
|
||||||
@@ -67,7 +97,8 @@ func (r *LightRenderer) stderrInternal(str string, allowNLCR bool, resetCode str
|
|||||||
for len(bytes) > 0 {
|
for len(bytes) > 0 {
|
||||||
r, sz := utf8.DecodeRune(bytes)
|
r, sz := utf8.DecodeRune(bytes)
|
||||||
nlcr := r == '\n' || r == '\r'
|
nlcr := r == '\n' || r == '\r'
|
||||||
if r >= 32 || r == '\x1b' || nlcr {
|
isC1 := r >= 0x80 && r <= 0x9F
|
||||||
|
if (r >= 32 && !isC1) || r == '\x1b' || nlcr {
|
||||||
if nlcr && !allowNLCR {
|
if nlcr && !allowNLCR {
|
||||||
if r == '\r' {
|
if r == '\r' {
|
||||||
runes = append(runes, []rune(CR+resetCode)...)
|
runes = append(runes, []rune(CR+resetCode)...)
|
||||||
@@ -89,14 +120,36 @@ func (r *LightRenderer) csi(code string) string {
|
|||||||
return fullcode
|
return fullcode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setSGR emits the given SGR sequence only when the terminal is not already
|
||||||
|
// in that state. Sequences built by csiColor start with a reset parameter,
|
||||||
|
// so each of them fully determines the state on its own. The zero value of
|
||||||
|
// r.sgr never matches a sequence, so the first update after Init, Resume, or
|
||||||
|
// invalidateSGR is always emitted.
|
||||||
|
func (r *LightRenderer) setSGR(code string) {
|
||||||
|
if code != r.sgr {
|
||||||
|
r.stderr(code)
|
||||||
|
r.sgr = code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// invalidateSGR marks the terminal SGR state as unknown, e.g. after raw
|
||||||
|
// output that may have changed it behind the renderer's back.
|
||||||
|
func (r *LightRenderer) invalidateSGR() {
|
||||||
|
r.sgr = ""
|
||||||
|
}
|
||||||
|
|
||||||
func (r *LightRenderer) flush() {
|
func (r *LightRenderer) flush() {
|
||||||
if r.queued.Len() > 0 {
|
if r.queued.Len() > 0 {
|
||||||
raw := "\x1b[?7l\x1b[?25l" + r.queued.String()
|
// Leave the terminal in the default SGR state between frames
|
||||||
|
r.setSGR("\x1b[0m")
|
||||||
|
// Wrap the frame in synchronized update mode (2026) so that the
|
||||||
|
// terminal applies it atomically. Terminals without support ignore
|
||||||
|
// the unknown private mode and behave as before.
|
||||||
|
raw := "\x1b[?2026h\x1b[?7l\x1b[?25l" + r.queued.String()
|
||||||
if r.showCursor {
|
if r.showCursor {
|
||||||
raw += "\x1b[?25h\x1b[?7h"
|
raw += "\x1b[?25h"
|
||||||
} else {
|
|
||||||
raw += "\x1b[?7h"
|
|
||||||
}
|
}
|
||||||
|
raw += "\x1b[?7h\x1b[?2026l"
|
||||||
r.flushRaw(raw)
|
r.flushRaw(raw)
|
||||||
r.queued.Reset()
|
r.queued.Reset()
|
||||||
}
|
}
|
||||||
@@ -127,12 +180,17 @@ type LightRenderer struct {
|
|||||||
fullscreen bool
|
fullscreen bool
|
||||||
upOneLine bool
|
upOneLine bool
|
||||||
queued strings.Builder
|
queued strings.Builder
|
||||||
|
sgr string
|
||||||
y int
|
y int
|
||||||
x int
|
x int
|
||||||
maxHeightFunc func(int) int
|
maxHeightFunc func(int) int
|
||||||
showCursor bool
|
showCursor bool
|
||||||
mutex sync.Mutex
|
mutex sync.Mutex
|
||||||
|
|
||||||
|
// Whether bracketed paste was already on before we enabled it. Nil when
|
||||||
|
// the terminal did not answer the query.
|
||||||
|
pasteWasSet *bool
|
||||||
|
|
||||||
// Windows only
|
// Windows only
|
||||||
ttyinChannel chan byte
|
ttyinChannel chan byte
|
||||||
inHandle uintptr
|
inHandle uintptr
|
||||||
@@ -205,8 +263,13 @@ func (r *LightRenderer) Init() error {
|
|||||||
|
|
||||||
if r.fullscreen {
|
if r.fullscreen {
|
||||||
r.smcup()
|
r.smcup()
|
||||||
} else {
|
}
|
||||||
y, x := r.findOffset()
|
|
||||||
|
// Ask everything in one round trip, before the offset is needed.
|
||||||
|
y, x, pasteWasSet := r.queryStartup()
|
||||||
|
r.pasteWasSet = pasteWasSet
|
||||||
|
|
||||||
|
if !r.fullscreen {
|
||||||
r.mouse = r.mouse && y >= 0
|
r.mouse = r.mouse && y >= 0
|
||||||
// When --no-clear is used for repetitive relaunching, there is a small
|
// When --no-clear is used for repetitive relaunching, there is a small
|
||||||
// time frame between fzf processes where the user keystrokes are not
|
// time frame between fzf processes where the user keystrokes are not
|
||||||
@@ -231,7 +294,7 @@ func (r *LightRenderer) Init() error {
|
|||||||
r.csi("G")
|
r.csi("G")
|
||||||
r.csi("K")
|
r.csi("K")
|
||||||
if !r.clearOnExit && !r.fullscreen {
|
if !r.clearOnExit && !r.fullscreen {
|
||||||
r.csi("s")
|
r.stderr("\x1b7") // DECSC: save cursor position
|
||||||
}
|
}
|
||||||
if !r.fullscreen && r.mouse {
|
if !r.fullscreen && r.mouse {
|
||||||
r.yoffset, _ = r.findOffset()
|
r.yoffset, _ = r.findOffset()
|
||||||
@@ -249,15 +312,15 @@ func (r *LightRenderer) makeSpace() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *LightRenderer) move(y int, x int) {
|
func (r *LightRenderer) move(y int, x int) {
|
||||||
// w.csi("u")
|
|
||||||
if r.y < y {
|
if r.y < y {
|
||||||
r.csi(fmt.Sprintf("%dB", y-r.y))
|
r.csi(fmt.Sprintf("%dB", y-r.y))
|
||||||
} else if r.y > y {
|
} else if r.y > y {
|
||||||
r.csi(fmt.Sprintf("%dA", r.y-y))
|
r.csi(fmt.Sprintf("%dA", r.y-y))
|
||||||
}
|
}
|
||||||
|
if x == 0 {
|
||||||
r.stderr("\r")
|
r.stderr("\r")
|
||||||
if x > 0 {
|
} else {
|
||||||
r.csi(fmt.Sprintf("%dC", x))
|
r.csi(fmt.Sprintf("%dG", x+1))
|
||||||
}
|
}
|
||||||
r.y = y
|
r.y = y
|
||||||
r.x = x
|
r.x = x
|
||||||
@@ -293,7 +356,11 @@ func (r *LightRenderer) getBytesInternal(cancellable bool, buffer []byte, nonblo
|
|||||||
if c == Esc.Int() || nonblock {
|
if c == Esc.Int() || nonblock {
|
||||||
retries = r.escDelay / escPollInterval
|
retries = r.escDelay / escPollInterval
|
||||||
}
|
}
|
||||||
|
// A non-blocking read that found nothing has no byte to record. Recording
|
||||||
|
// one would put a NUL in the middle of a reply still being assembled.
|
||||||
|
if result.ok() {
|
||||||
buffer = append(buffer, byte(c))
|
buffer = append(buffer, byte(c))
|
||||||
|
}
|
||||||
|
|
||||||
pc := c
|
pc := c
|
||||||
for {
|
for {
|
||||||
@@ -421,6 +488,12 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
|||||||
return Event{Invalid, 0, nil}
|
return Event{Invalid, 0, nil}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loc = pasteModeRegexpBegin.FindIndex(r.buffer)
|
||||||
|
if loc != nil && loc[0] == 0 {
|
||||||
|
*sz = loc[1]
|
||||||
|
return Event{Invalid, 0, nil}
|
||||||
|
}
|
||||||
|
|
||||||
*sz = 2
|
*sz = 2
|
||||||
if r.buffer[1] == 8 {
|
if r.buffer[1] == 8 {
|
||||||
return Event{CtrlAltBackspace, 0, nil}
|
return Event{CtrlAltBackspace, 0, nil}
|
||||||
@@ -689,6 +762,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
|||||||
switch r.buffer[4] {
|
switch r.buffer[4] {
|
||||||
case '1', '2', '3', '4', '5', '6', '7', '8', '9':
|
case '1', '2', '3', '4', '5', '6', '7', '8', '9':
|
||||||
// Kitty iTerm2 WezTerm
|
// Kitty iTerm2 WezTerm
|
||||||
|
// ARROW "\e[1;1D"
|
||||||
// SHIFT-ARROW "\e[1;2D"
|
// SHIFT-ARROW "\e[1;2D"
|
||||||
// ALT-SHIFT-ARROW "\e[1;4D" "\e[1;10D" "\e[1;4D"
|
// ALT-SHIFT-ARROW "\e[1;4D" "\e[1;10D" "\e[1;4D"
|
||||||
// CTRL-SHIFT-ARROW "\e[1;6D" N/A
|
// CTRL-SHIFT-ARROW "\e[1;6D" N/A
|
||||||
@@ -743,6 +817,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
|||||||
if shift {
|
if shift {
|
||||||
return Event{ShiftUp, 0, nil}
|
return Event{ShiftUp, 0, nil}
|
||||||
}
|
}
|
||||||
|
return Event{Up, 0, nil}
|
||||||
case 'B':
|
case 'B':
|
||||||
if ctrlAltShift {
|
if ctrlAltShift {
|
||||||
return Event{CtrlAltShiftDown, 0, nil}
|
return Event{CtrlAltShiftDown, 0, nil}
|
||||||
@@ -765,6 +840,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
|||||||
if shift {
|
if shift {
|
||||||
return Event{ShiftDown, 0, nil}
|
return Event{ShiftDown, 0, nil}
|
||||||
}
|
}
|
||||||
|
return Event{Down, 0, nil}
|
||||||
case 'C':
|
case 'C':
|
||||||
if ctrlAltShift {
|
if ctrlAltShift {
|
||||||
return Event{CtrlAltShiftRight, 0, nil}
|
return Event{CtrlAltShiftRight, 0, nil}
|
||||||
@@ -787,6 +863,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
|||||||
if alt {
|
if alt {
|
||||||
return Event{AltRight, 0, nil}
|
return Event{AltRight, 0, nil}
|
||||||
}
|
}
|
||||||
|
return Event{Right, 0, nil}
|
||||||
case 'D':
|
case 'D':
|
||||||
if ctrlAltShift {
|
if ctrlAltShift {
|
||||||
return Event{CtrlAltShiftLeft, 0, nil}
|
return Event{CtrlAltShiftLeft, 0, nil}
|
||||||
@@ -809,6 +886,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
|||||||
if shift {
|
if shift {
|
||||||
return Event{ShiftLeft, 0, nil}
|
return Event{ShiftLeft, 0, nil}
|
||||||
}
|
}
|
||||||
|
return Event{Left, 0, nil}
|
||||||
case 'H':
|
case 'H':
|
||||||
if ctrlAltShift {
|
if ctrlAltShift {
|
||||||
return Event{CtrlAltShiftHome, 0, nil}
|
return Event{CtrlAltShiftHome, 0, nil}
|
||||||
@@ -831,6 +909,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
|||||||
if shift {
|
if shift {
|
||||||
return Event{ShiftHome, 0, nil}
|
return Event{ShiftHome, 0, nil}
|
||||||
}
|
}
|
||||||
|
return Event{Home, 0, nil}
|
||||||
case 'F':
|
case 'F':
|
||||||
if ctrlAltShift {
|
if ctrlAltShift {
|
||||||
return Event{CtrlAltShiftEnd, 0, nil}
|
return Event{CtrlAltShiftEnd, 0, nil}
|
||||||
@@ -853,6 +932,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
|||||||
if shift {
|
if shift {
|
||||||
return Event{ShiftEnd, 0, nil}
|
return Event{ShiftEnd, 0, nil}
|
||||||
}
|
}
|
||||||
|
return Event{End, 0, nil}
|
||||||
}
|
}
|
||||||
} // r.buffer[4]
|
} // r.buffer[4]
|
||||||
} // r.buffer[3]
|
} // r.buffer[3]
|
||||||
@@ -897,12 +977,15 @@ func (r *LightRenderer) mouseSequence(sz *int) Event {
|
|||||||
down := rest[end] == 'M'
|
down := rest[end] == 'M'
|
||||||
|
|
||||||
scroll := 0
|
scroll := 0
|
||||||
if t >= 64 {
|
wheel := t >= 64
|
||||||
|
if wheel {
|
||||||
t -= 64
|
t -= 64
|
||||||
if t&0b1 == 1 {
|
// SGR wheel button codes: 64=up, 65=down, 66=left, 67=right
|
||||||
scroll = -1
|
switch t & 0b11 {
|
||||||
} else {
|
case 0:
|
||||||
scroll = 1
|
scroll = 1
|
||||||
|
case 1:
|
||||||
|
scroll = -1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -913,7 +996,7 @@ func (r *LightRenderer) mouseSequence(sz *int) Event {
|
|||||||
shift := t&0b00100 > 0
|
shift := t&0b00100 > 0
|
||||||
drag := t&0b100000 > 0 // 32
|
drag := t&0b100000 > 0 // 32
|
||||||
|
|
||||||
if scroll != 0 {
|
if wheel {
|
||||||
return Event{Mouse, 0, &MouseEvent{y, x, scroll, false, false, false, ctrl, alt, shift}}
|
return Event{Mouse, 0, &MouseEvent{y, x, scroll, false, false, false, ctrl, alt, shift}}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -984,11 +1067,23 @@ func (r *LightRenderer) disableMouse() {
|
|||||||
|
|
||||||
func (r *LightRenderer) disableModes() {
|
func (r *LightRenderer) disableModes() {
|
||||||
r.disableMouse()
|
r.disableMouse()
|
||||||
|
// Put bracketed paste back the way we found it. A shell that runs fzf from
|
||||||
|
// a line editor widget re-enables the mode only when the editor starts, so
|
||||||
|
// forcing it off here would leave it off for the rest of the session.
|
||||||
|
// Terminals that did not answer the query fall back to disabling, which is
|
||||||
|
// what fzf has always done.
|
||||||
|
if r.pasteWasSet != nil && *r.pasteWasSet {
|
||||||
|
r.csi("?2004h")
|
||||||
|
} else {
|
||||||
r.csi("?2004l")
|
r.csi("?2004l")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *LightRenderer) Resume(clear bool, sigcont bool) {
|
func (r *LightRenderer) Resume(clear bool, sigcont bool) {
|
||||||
r.setupTerminal()
|
r.setupTerminal()
|
||||||
|
// The programs that ran in the meantime may have left the terminal in an
|
||||||
|
// arbitrary SGR state
|
||||||
|
r.invalidateSGR()
|
||||||
if clear {
|
if clear {
|
||||||
if r.fullscreen {
|
if r.fullscreen {
|
||||||
r.smcup()
|
r.smcup()
|
||||||
@@ -1010,7 +1105,6 @@ func (r *LightRenderer) Clear() {
|
|||||||
if r.fullscreen {
|
if r.fullscreen {
|
||||||
r.csi("H")
|
r.csi("H")
|
||||||
}
|
}
|
||||||
// r.csi("u")
|
|
||||||
r.origin()
|
r.origin()
|
||||||
r.csi("J")
|
r.csi("J")
|
||||||
r.flush()
|
r.flush()
|
||||||
@@ -1033,7 +1127,6 @@ func (r *LightRenderer) Refresh() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *LightRenderer) Close() {
|
func (r *LightRenderer) Close() {
|
||||||
// r.csi("u")
|
|
||||||
if r.clearOnExit {
|
if r.clearOnExit {
|
||||||
if r.fullscreen {
|
if r.fullscreen {
|
||||||
r.rmcup()
|
r.rmcup()
|
||||||
@@ -1045,7 +1138,7 @@ func (r *LightRenderer) Close() {
|
|||||||
r.csi("J")
|
r.csi("J")
|
||||||
}
|
}
|
||||||
} else if !r.fullscreen {
|
} else if !r.fullscreen {
|
||||||
r.csi("u")
|
r.stderr("\x1b8") // DECRC: restore cursor position
|
||||||
}
|
}
|
||||||
if !r.showCursor {
|
if !r.showCursor {
|
||||||
r.csi("?25h")
|
r.csi("?25h")
|
||||||
@@ -1122,131 +1215,144 @@ func (w *LightWindow) DrawHBorder() {
|
|||||||
w.drawBorder(true)
|
w.drawBorder(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// drawHLine fills row `row` with `line` between optional left/right caps.
|
||||||
|
// A zero rune means "no cap"; caps are placed at the very edges of `w`.
|
||||||
|
func (w *LightWindow) drawHLine(row int, line, leftCap, rightCap rune, color ColorPair) {
|
||||||
|
w.Move(row, 0)
|
||||||
|
hw := runeWidth(line)
|
||||||
|
width := w.width
|
||||||
|
if leftCap != 0 {
|
||||||
|
w.CPrint(color, string(leftCap))
|
||||||
|
width -= runeWidth(leftCap)
|
||||||
|
}
|
||||||
|
if rightCap != 0 {
|
||||||
|
width -= runeWidth(rightCap)
|
||||||
|
}
|
||||||
|
if width < 0 {
|
||||||
|
width = 0
|
||||||
|
}
|
||||||
|
inner := width / hw
|
||||||
|
rem := width - inner*hw
|
||||||
|
w.CPrint(color, repeat(line, inner)+repeat(' ', rem))
|
||||||
|
if rightCap != 0 {
|
||||||
|
w.CPrint(color, string(rightCap))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *LightWindow) DrawHSeparator(row int, windowType WindowType, useBottom bool) {
|
||||||
|
if w.height == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
shape := w.border.shape
|
||||||
|
if shape == BorderNone {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
color := BorderColor(windowType)
|
||||||
|
line := w.border.top
|
||||||
|
if useBottom {
|
||||||
|
line = w.border.bottom
|
||||||
|
}
|
||||||
|
var leftCap, rightCap rune
|
||||||
|
if shape.HasLeft() {
|
||||||
|
leftCap = w.border.leftMid
|
||||||
|
}
|
||||||
|
if shape.HasRight() {
|
||||||
|
rightCap = w.border.rightMid
|
||||||
|
}
|
||||||
|
w.drawHLine(row, line, leftCap, rightCap, color)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *LightWindow) PaintSectionFrame(topContent, bottomContent int, windowType WindowType, edge SectionEdge) {
|
||||||
|
if w.height == 0 || w.border.shape == BorderNone {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
color := BorderColor(windowType)
|
||||||
|
shape := w.border.shape
|
||||||
|
hasLeft := shape.HasLeft()
|
||||||
|
hasRight := shape.HasRight()
|
||||||
|
rightW := runeWidth(w.border.right)
|
||||||
|
// Content rows: overpaint left/right verticals + their 1-char margin.
|
||||||
|
for row := topContent; row <= bottomContent; row++ {
|
||||||
|
if hasLeft {
|
||||||
|
w.Move(row, 0)
|
||||||
|
w.CPrint(color, string(w.border.left)+" ")
|
||||||
|
}
|
||||||
|
if hasRight {
|
||||||
|
w.Move(row, w.width-rightW-1)
|
||||||
|
w.CPrint(color, " "+string(w.border.right))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if edge == SectionEdgeTop && shape.HasTop() {
|
||||||
|
var leftCap, rightCap rune
|
||||||
|
if hasLeft {
|
||||||
|
leftCap = w.border.topLeft
|
||||||
|
}
|
||||||
|
if hasRight {
|
||||||
|
rightCap = w.border.topRight
|
||||||
|
}
|
||||||
|
w.drawHLine(0, w.border.top, leftCap, rightCap, color)
|
||||||
|
}
|
||||||
|
if edge == SectionEdgeBottom && shape.HasBottom() {
|
||||||
|
var leftCap, rightCap rune
|
||||||
|
if hasLeft {
|
||||||
|
leftCap = w.border.bottomLeft
|
||||||
|
}
|
||||||
|
if hasRight {
|
||||||
|
rightCap = w.border.bottomRight
|
||||||
|
}
|
||||||
|
w.drawHLine(w.height-1, w.border.bottom, leftCap, rightCap, color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (w *LightWindow) drawBorder(onlyHorizontal bool) {
|
func (w *LightWindow) drawBorder(onlyHorizontal bool) {
|
||||||
if w.height == 0 {
|
if w.height == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
switch w.border.shape {
|
shape := w.border.shape
|
||||||
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble:
|
if shape == BorderNone {
|
||||||
w.drawBorderAround(onlyHorizontal)
|
|
||||||
case BorderHorizontal:
|
|
||||||
w.drawBorderHorizontal(true, true)
|
|
||||||
case BorderVertical:
|
|
||||||
if onlyHorizontal {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.drawBorderVertical(true, true)
|
color := BorderColor(w.windowType)
|
||||||
case BorderTop:
|
hasLeft := shape.HasLeft()
|
||||||
w.drawBorderHorizontal(true, false)
|
hasRight := shape.HasRight()
|
||||||
case BorderBottom:
|
|
||||||
w.drawBorderHorizontal(false, true)
|
|
||||||
case BorderLeft:
|
|
||||||
if onlyHorizontal {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.drawBorderVertical(true, false)
|
|
||||||
case BorderRight:
|
|
||||||
if onlyHorizontal {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.drawBorderVertical(false, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *LightWindow) drawBorderHorizontal(top, bottom bool) {
|
if shape.HasTop() {
|
||||||
color := ColBorder
|
var leftCap, rightCap rune
|
||||||
switch w.windowType {
|
if hasLeft {
|
||||||
case WindowList:
|
leftCap = w.border.topLeft
|
||||||
color = ColListBorder
|
|
||||||
case WindowInput:
|
|
||||||
color = ColInputBorder
|
|
||||||
case WindowHeader:
|
|
||||||
color = ColHeaderBorder
|
|
||||||
case WindowFooter:
|
|
||||||
color = ColFooterBorder
|
|
||||||
case WindowPreview:
|
|
||||||
color = ColPreviewBorder
|
|
||||||
}
|
}
|
||||||
hw := runeWidth(w.border.top)
|
if hasRight {
|
||||||
if top {
|
rightCap = w.border.topRight
|
||||||
w.Move(0, 0)
|
|
||||||
w.CPrint(color, repeat(w.border.top, w.width/hw))
|
|
||||||
}
|
}
|
||||||
|
w.drawHLine(0, w.border.top, leftCap, rightCap, color)
|
||||||
if bottom {
|
|
||||||
w.Move(w.height-1, 0)
|
|
||||||
w.CPrint(color, repeat(w.border.bottom, w.width/hw))
|
|
||||||
}
|
}
|
||||||
}
|
if !onlyHorizontal && (hasLeft || hasRight) {
|
||||||
|
|
||||||
func (w *LightWindow) drawBorderVertical(left, right bool) {
|
|
||||||
vw := runeWidth(w.border.left)
|
vw := runeWidth(w.border.left)
|
||||||
color := ColBorder
|
|
||||||
switch w.windowType {
|
|
||||||
case WindowList:
|
|
||||||
color = ColListBorder
|
|
||||||
case WindowInput:
|
|
||||||
color = ColInputBorder
|
|
||||||
case WindowHeader:
|
|
||||||
color = ColHeaderBorder
|
|
||||||
case WindowFooter:
|
|
||||||
color = ColFooterBorder
|
|
||||||
case WindowPreview:
|
|
||||||
color = ColPreviewBorder
|
|
||||||
}
|
|
||||||
for y := 0; y < w.height; y++ {
|
for y := 0; y < w.height; y++ {
|
||||||
if left {
|
// Corner rows are already painted by drawHLine above / below.
|
||||||
|
if (y == 0 && shape.HasTop()) || (y == w.height-1 && shape.HasBottom()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if hasLeft {
|
||||||
w.Move(y, 0)
|
w.Move(y, 0)
|
||||||
w.CPrint(color, string(w.border.left))
|
w.CPrint(color, string(w.border.left)+" ")
|
||||||
w.CPrint(color, " ") // Margin
|
|
||||||
}
|
}
|
||||||
if right {
|
if hasRight {
|
||||||
w.Move(y, w.width-vw-1)
|
w.Move(y, w.width-vw-1)
|
||||||
w.CPrint(color, " ") // Margin
|
w.CPrint(color, " "+string(w.border.right))
|
||||||
w.CPrint(color, string(w.border.right))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func (w *LightWindow) drawBorderAround(onlyHorizontal bool) {
|
|
||||||
w.Move(0, 0)
|
|
||||||
color := ColBorder
|
|
||||||
switch w.windowType {
|
|
||||||
case WindowList:
|
|
||||||
color = ColListBorder
|
|
||||||
case WindowInput:
|
|
||||||
color = ColInputBorder
|
|
||||||
case WindowHeader:
|
|
||||||
color = ColHeaderBorder
|
|
||||||
case WindowFooter:
|
|
||||||
color = ColFooterBorder
|
|
||||||
case WindowPreview:
|
|
||||||
color = ColPreviewBorder
|
|
||||||
}
|
}
|
||||||
hw := runeWidth(w.border.top)
|
if shape.HasBottom() {
|
||||||
tcw := runeWidth(w.border.topLeft) + runeWidth(w.border.topRight)
|
var leftCap, rightCap rune
|
||||||
bcw := runeWidth(w.border.bottomLeft) + runeWidth(w.border.bottomRight)
|
if hasLeft {
|
||||||
rem := (w.width - tcw) % hw
|
leftCap = w.border.bottomLeft
|
||||||
w.CPrint(color, string(w.border.topLeft)+repeat(w.border.top, (w.width-tcw)/hw)+repeat(' ', rem)+string(w.border.topRight))
|
|
||||||
if !onlyHorizontal {
|
|
||||||
vw := runeWidth(w.border.left)
|
|
||||||
for y := 1; y < w.height-1; y++ {
|
|
||||||
w.Move(y, 0)
|
|
||||||
w.CPrint(color, string(w.border.left))
|
|
||||||
w.CPrint(color, " ") // Margin
|
|
||||||
|
|
||||||
w.Move(y, w.width-vw-1)
|
|
||||||
w.CPrint(color, " ") // Margin
|
|
||||||
w.CPrint(color, string(w.border.right))
|
|
||||||
}
|
}
|
||||||
|
if hasRight {
|
||||||
|
rightCap = w.border.bottomRight
|
||||||
|
}
|
||||||
|
w.drawHLine(w.height-1, w.border.bottom, leftCap, rightCap, color)
|
||||||
}
|
}
|
||||||
w.Move(w.height-1, 0)
|
|
||||||
rem = (w.width - bcw) % hw
|
|
||||||
w.CPrint(color, string(w.border.bottomLeft)+repeat(w.border.bottom, (w.width-bcw)/hw)+repeat(' ', rem)+string(w.border.bottomRight))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *LightWindow) csi(code string) string {
|
|
||||||
return w.renderer.csi(code)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *LightWindow) stderrInternal(str string, allowNLCR bool, resetCode string) {
|
func (w *LightWindow) stderrInternal(str string, allowNLCR bool, resetCode string) {
|
||||||
@@ -1387,13 +1493,18 @@ func ulColorCode(c Color) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// csiColor builds the SGR sequence for the given colors and attributes
|
||||||
|
// without emitting it. The sequence starts with a reset parameter, so it
|
||||||
|
// fully determines the SGR state on its own.
|
||||||
func (w *LightWindow) csiColor(fg Color, bg Color, ul Color, attr Attr) (bool, string) {
|
func (w *LightWindow) csiColor(fg Color, bg Color, ul Color, attr Attr) (bool, string) {
|
||||||
codes := append(attrCodes(attr), colorCodes(fg, bg)...)
|
codes := append(attrCodes(attr), colorCodes(fg, bg)...)
|
||||||
if ulCode := ulColorCode(ul); ulCode != "" {
|
if ulCode := ulColorCode(ul); ulCode != "" {
|
||||||
codes = append(codes, ulCode)
|
codes = append(codes, ulCode)
|
||||||
}
|
}
|
||||||
code := w.csi(";" + strings.Join(codes, ";") + "m")
|
if len(codes) == 0 {
|
||||||
return len(codes) > 0, code
|
return false, "\x1b[0m"
|
||||||
|
}
|
||||||
|
return true, "\x1b[;" + strings.Join(codes, ";") + "m"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *LightWindow) Print(text string) {
|
func (w *LightWindow) Print(text string) {
|
||||||
@@ -1406,15 +1517,13 @@ func cleanse(str string) string {
|
|||||||
|
|
||||||
func (w *LightWindow) CPrint(pair ColorPair, text string) {
|
func (w *LightWindow) CPrint(pair ColorPair, text string) {
|
||||||
_, code := w.csiColor(pair.Fg(), pair.Bg(), pair.Ul(), pair.Attr())
|
_, code := w.csiColor(pair.Fg(), pair.Bg(), pair.Ul(), pair.Attr())
|
||||||
|
w.renderer.setSGR(code)
|
||||||
w.stderrInternal(cleanse(text), false, code)
|
w.stderrInternal(cleanse(text), false, code)
|
||||||
w.csi("0m")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *LightWindow) cprint2(fg Color, bg Color, attr Attr, text string) {
|
func (w *LightWindow) cprint2(fg Color, bg Color, attr Attr, text string) {
|
||||||
hasColors, code := w.csiColor(fg, bg, colDefault, attr)
|
_, code := w.csiColor(fg, bg, colDefault, attr)
|
||||||
if hasColors {
|
w.renderer.setSGR(code)
|
||||||
defer w.csi("0m")
|
|
||||||
}
|
|
||||||
w.stderrInternal(cleanse(text), false, code)
|
w.stderrInternal(cleanse(text), false, code)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1435,7 +1544,7 @@ func (w *LightWindow) fill(str string, resetCode string) FillReturn {
|
|||||||
}
|
}
|
||||||
w.MoveAndClear(w.posy, w.posx)
|
w.MoveAndClear(w.posy, w.posx)
|
||||||
w.Move(w.posy+1, 0)
|
w.Move(w.posy+1, 0)
|
||||||
w.renderer.stderr(resetCode)
|
w.renderer.setSGR(resetCode)
|
||||||
if len(lines) > 1 {
|
if len(lines) > 1 {
|
||||||
sign := w.wrapSign
|
sign := w.wrapSign
|
||||||
width := w.wrapSignWidth
|
width := w.wrapSignWidth
|
||||||
@@ -1445,7 +1554,8 @@ func (w *LightWindow) fill(str string, resetCode string) FillReturn {
|
|||||||
width = truncatedWidth
|
width = truncatedWidth
|
||||||
}
|
}
|
||||||
w.stderrInternal(DIM+sign, false, resetCode)
|
w.stderrInternal(DIM+sign, false, resetCode)
|
||||||
w.renderer.stderr(resetCode)
|
w.renderer.invalidateSGR()
|
||||||
|
w.renderer.setSGR(resetCode)
|
||||||
w.Move(w.posy, width)
|
w.Move(w.posy, width)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1456,20 +1566,19 @@ func (w *LightWindow) fill(str string, resetCode string) FillReturn {
|
|||||||
return FillSuspend
|
return FillSuspend
|
||||||
}
|
}
|
||||||
w.Move(w.posy+1, 0)
|
w.Move(w.posy+1, 0)
|
||||||
w.renderer.stderr(resetCode)
|
w.renderer.setSGR(resetCode)
|
||||||
return FillNextLine
|
return FillNextLine
|
||||||
}
|
}
|
||||||
return FillContinue
|
return FillContinue
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *LightWindow) setBg() string {
|
func (w *LightWindow) setBg() string {
|
||||||
if w.bg != colDefault {
|
// The plain reset code for the default background is still required to
|
||||||
_, code := w.csiColor(colDefault, w.bg, colDefault, AttrRegular)
|
// clear the dim attribute after ␍ in the preview window
|
||||||
return code
|
|
||||||
}
|
|
||||||
// Should clear dim attribute after ␍ in the preview window
|
|
||||||
// e.g. printf "foo\rbar" | fzf --ansi --preview 'printf "foo\rbar"'
|
// e.g. printf "foo\rbar" | fzf --ansi --preview 'printf "foo\rbar"'
|
||||||
return "\x1b[m"
|
_, code := w.csiColor(colDefault, w.bg, colDefault, AttrRegular)
|
||||||
|
w.renderer.setSGR(code)
|
||||||
|
return code
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *LightWindow) LinkBegin(uri string, params string) {
|
func (w *LightWindow) LinkBegin(uri string, params string) {
|
||||||
@@ -1495,7 +1604,7 @@ func (w *LightWindow) CFill(fg Color, bg Color, ul Color, attr Attr, text string
|
|||||||
bg = w.bg
|
bg = w.bg
|
||||||
}
|
}
|
||||||
if hasColors, resetCode := w.csiColor(fg, bg, ul, attr); hasColors {
|
if hasColors, resetCode := w.csiColor(fg, bg, ul, attr); hasColors {
|
||||||
defer w.csi("0m")
|
w.renderer.setSGR(resetCode)
|
||||||
return w.fill(text, resetCode)
|
return w.fill(text, resetCode)
|
||||||
}
|
}
|
||||||
return w.fill(text, w.setBg())
|
return w.fill(text, w.setBg())
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Drives queryStartup against a terminal simulated by pipes, with the replies
|
||||||
|
// already queued so the exchange is deterministic.
|
||||||
|
func replyingTerminal(t *testing.T, replies string) (*LightRenderer, func() string) {
|
||||||
|
t.Helper()
|
||||||
|
inR, inW, err := os.Pipe()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
outR, outW, err := os.Pipe()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := inW.WriteString(replies); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { inR.Close(); inW.Close(); outR.Close() })
|
||||||
|
|
||||||
|
r := &LightRenderer{ttyin: inR, ttyout: outW, escDelay: defaultEscDelay}
|
||||||
|
written := func() string {
|
||||||
|
outW.Close()
|
||||||
|
var sb strings.Builder
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
for {
|
||||||
|
n, err := outR.Read(buf)
|
||||||
|
sb.Write(buf[:n])
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
return r, written
|
||||||
|
}
|
||||||
|
|
||||||
|
const eraseEcho = "\b \b"
|
||||||
|
|
||||||
|
func TestQueryStartup(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
replies string
|
||||||
|
row, col int
|
||||||
|
paste string // "", "true" or "false"
|
||||||
|
erase int // columns the echo took, and so erasures expected
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "mode already set",
|
||||||
|
replies: "\x1b[5;10R\x1b[?2004;1$y\x1b[5;10R",
|
||||||
|
row: 4, col: 9, paste: "true",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mode reset",
|
||||||
|
replies: "\x1b[5;10R\x1b[?2004;2$y\x1b[5;10R",
|
||||||
|
row: 4, col: 9, paste: "false",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mode not recognized",
|
||||||
|
replies: "\x1b[5;10R\x1b[?2004;0$y\x1b[5;10R",
|
||||||
|
row: 4, col: 9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Answers the position queries but ignores DECRQM without printing
|
||||||
|
name: "query ignored",
|
||||||
|
replies: "\x1b[5;10R\x1b[5;10R",
|
||||||
|
row: 4, col: 9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// macOS Terminal.app: ends the sequence at '$' and prints the 'p'
|
||||||
|
name: "query echoed",
|
||||||
|
replies: "\x1b[5;10R\x1b[5;11R",
|
||||||
|
row: 4, col: 9, erase: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// A terminal that prints more of what it could not parse
|
||||||
|
name: "longer echo",
|
||||||
|
replies: "\x1b[5;10R\x1b[5;13R",
|
||||||
|
row: 4, col: 9, erase: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The echo wrapped to the next line, where erasing would guess
|
||||||
|
name: "echo wrapped",
|
||||||
|
replies: "\x1b[5;80R\x1b[6;1R",
|
||||||
|
row: 4, col: 79,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no reply at all",
|
||||||
|
replies: "",
|
||||||
|
row: -1, col: -1,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
r, written := replyingTerminal(t, tc.replies)
|
||||||
|
row, col, pasteWasSet := r.queryStartup()
|
||||||
|
|
||||||
|
if row != tc.row || col != tc.col {
|
||||||
|
t.Errorf("offset (%d,%d), want (%d,%d)", row, col, tc.row, tc.col)
|
||||||
|
}
|
||||||
|
paste := ""
|
||||||
|
if pasteWasSet != nil {
|
||||||
|
paste = "false"
|
||||||
|
if *pasteWasSet {
|
||||||
|
paste = "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if paste != tc.paste {
|
||||||
|
t.Errorf("pasteWasSet %q, want %q", paste, tc.paste)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := written()
|
||||||
|
if got := strings.Count(out, eraseEcho); got != tc.erase {
|
||||||
|
t.Errorf("erased %d columns, want %d (wrote %q)", got, tc.erase, out)
|
||||||
|
}
|
||||||
|
// The paste mode query has to sit between two position queries
|
||||||
|
if want := "\x1b[6n\x1b[?2004$p\x1b[6n"; !strings.Contains(out, want) {
|
||||||
|
t.Errorf("queries %q, want %q in order", out, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package tui
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"unicode"
|
"unicode"
|
||||||
)
|
)
|
||||||
@@ -350,3 +351,130 @@ func TestLightRenderer(t *testing.T) {
|
|||||||
assertEscSequence("\x1b[14~", "f4")
|
assertEscSequence("\x1b[14~", "f4")
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLightRendererScrollWheel(t *testing.T) {
|
||||||
|
tty_file, _ := os.Open("")
|
||||||
|
renderer, _ := NewLightRenderer(
|
||||||
|
"", tty_file, &ColorTheme{}, true, true, 0, false, true,
|
||||||
|
func(h int) int { return h })
|
||||||
|
|
||||||
|
light_renderer := renderer.(*LightRenderer)
|
||||||
|
|
||||||
|
assertScroll := func(sequence string, scroll int, mods string) {
|
||||||
|
bytes := []byte(sequence)
|
||||||
|
light_renderer.buffer = bytes
|
||||||
|
|
||||||
|
sz := 1
|
||||||
|
event := light_renderer.escSequence(&sz)
|
||||||
|
me := event.MouseEvent
|
||||||
|
if event.Type != Mouse || me == nil {
|
||||||
|
t.Errorf("sequence: %q | got %s, want a Mouse event", sequence, event.Type.String())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
got := ""
|
||||||
|
if me.Ctrl {
|
||||||
|
got += "ctrl-"
|
||||||
|
}
|
||||||
|
if me.Alt {
|
||||||
|
got += "alt-"
|
||||||
|
}
|
||||||
|
if me.Shift {
|
||||||
|
got += "shift-"
|
||||||
|
}
|
||||||
|
got = strings.TrimSuffix(got, "-")
|
||||||
|
if me.S != scroll || got != mods || me.Down {
|
||||||
|
t.Errorf(
|
||||||
|
"sequence: %q | scroll=%d mods=%q down=%v != scroll=%d mods=%q down=false",
|
||||||
|
sequence, me.S, got, me.Down, scroll, mods)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertScroll("\x1b[<64;1;1M", 1, "") // up
|
||||||
|
assertScroll("\x1b[<65;1;1M", -1, "") // down
|
||||||
|
assertScroll("\x1b[<66;1;1M", 0, "") // left (ignored)
|
||||||
|
assertScroll("\x1b[<67;1;1M", 0, "") // right (ignored)
|
||||||
|
|
||||||
|
assertScroll("\x1b[<68;1;1M", 1, "shift") // shift + up
|
||||||
|
assertScroll("\x1b[<69;1;1M", -1, "shift") // shift + down
|
||||||
|
assertScroll("\x1b[<70;1;1M", 0, "shift") // shift + left (ignored)
|
||||||
|
assertScroll("\x1b[<71;1;1M", 0, "shift") // shift + right (ignored)
|
||||||
|
|
||||||
|
assertScroll("\x1b[<72;1;1M", 1, "alt") // alt + up
|
||||||
|
assertScroll("\x1b[<73;1;1M", -1, "alt") // alt + down
|
||||||
|
assertScroll("\x1b[<74;1;1M", 0, "alt") // alt + left (ignored)
|
||||||
|
assertScroll("\x1b[<75;1;1M", 0, "alt") // alt + right (ignored)
|
||||||
|
|
||||||
|
assertScroll("\x1b[<80;1;1M", 1, "ctrl") // ctrl + up
|
||||||
|
assertScroll("\x1b[<81;1;1M", -1, "ctrl") // ctrl + down
|
||||||
|
assertScroll("\x1b[<82;1;1M", 0, "ctrl") // ctrl + left (ignored)
|
||||||
|
assertScroll("\x1b[<83;1;1M", 0, "ctrl") // ctrl + right (ignored)
|
||||||
|
|
||||||
|
assertScroll("\x1b[<84;1;1M", 1, "ctrl-shift") // ctrl+shift + up
|
||||||
|
assertScroll("\x1b[<85;1;1M", -1, "ctrl-shift") // ctrl+shift + down
|
||||||
|
assertScroll("\x1b[<86;1;1M", 0, "ctrl-shift") // ctrl+shift + left (ignored)
|
||||||
|
assertScroll("\x1b[<87;1;1M", 0, "ctrl-shift") // ctrl+shift + right (ignored)
|
||||||
|
|
||||||
|
assertScroll("\x1b[<92;1;1M", 1, "ctrl-alt-shift") // ctrl+alt+shift + up
|
||||||
|
assertScroll("\x1b[<93;1;1M", -1, "ctrl-alt-shift") // ctrl+alt+shift + down
|
||||||
|
assertScroll("\x1b[<94;1;1M", 0, "ctrl-alt-shift") // ctrl+alt+shift + left (ignored)
|
||||||
|
assertScroll("\x1b[<95;1;1M", 0, "ctrl-alt-shift") // ctrl+alt+shift + right (ignored)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert the exact byte stream emitted for a scripted rendering sequence,
|
||||||
|
// locking down the SGR deduplication behavior of the light renderer.
|
||||||
|
func TestLightRendererSGRDeduplication(t *testing.T) {
|
||||||
|
renderer, _ := NewLightRenderer(
|
||||||
|
"", nil, &ColorTheme{}, true, false, 8, false, true,
|
||||||
|
func(h int) int { return h })
|
||||||
|
r := renderer.(*LightRenderer)
|
||||||
|
r.width = 80
|
||||||
|
r.height = 24
|
||||||
|
|
||||||
|
out, err := os.CreateTemp(t.TempDir(), "fzf-ttyout")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
r.ttyout = out
|
||||||
|
|
||||||
|
w := r.NewWindow(0, 0, 40, 4, WindowList, MakeBorderStyle(BorderNone, true), false).(*LightWindow)
|
||||||
|
w.fg = colDefault
|
||||||
|
w.bg = colDefault
|
||||||
|
r.queued.Reset()
|
||||||
|
|
||||||
|
red := NewColorPair(196, colDefault, AttrRegular)
|
||||||
|
blue := NewColorPair(21, colDefault, AttrRegular)
|
||||||
|
|
||||||
|
w.Move(0, 0)
|
||||||
|
w.CPrint(red, "ab")
|
||||||
|
w.CPrint(red, "cd") // same pair; no SGR expected
|
||||||
|
w.CPrint(blue, "ef")
|
||||||
|
w.Print("gh") // default colors; one reset expected
|
||||||
|
w.Print("ij") // still default; no SGR expected
|
||||||
|
w.Move(1, 2)
|
||||||
|
w.CPrint(red, "kl")
|
||||||
|
r.invalidateSGR()
|
||||||
|
w.CPrint(red, "mn") // re-emitted after invalidation
|
||||||
|
r.flush() // adds a trailing reset to leave the default state
|
||||||
|
|
||||||
|
expected := "\x1b[?2026h\x1b[?7l\x1b[?25l" +
|
||||||
|
"\r" +
|
||||||
|
"\x1b[;38;5;196mab" +
|
||||||
|
"cd" +
|
||||||
|
"\x1b[;38;5;21mef" +
|
||||||
|
"\x1b[0mgh" +
|
||||||
|
"ij" +
|
||||||
|
"\x1b[1B\x1b[3G" +
|
||||||
|
"\x1b[;38;5;196mkl" +
|
||||||
|
"\x1b[;38;5;196mmn" +
|
||||||
|
"\x1b[0m" +
|
||||||
|
"\x1b[?25h\x1b[?7h\x1b[?2026l"
|
||||||
|
|
||||||
|
bytes, err := os.ReadFile(out.Name())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(bytes) != expected {
|
||||||
|
t.Errorf("expected: %q\n got: %q", expected, string(bytes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+109
-13
@@ -8,6 +8,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/junegunn/fzf/src/util"
|
"github.com/junegunn/fzf/src/util"
|
||||||
"golang.org/x/sys/unix"
|
"golang.org/x/sys/unix"
|
||||||
@@ -93,25 +94,120 @@ func (r *LightRenderer) updateTerminalSize() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *LightRenderer) findOffset() (row int, col int) {
|
// waitReadable reports whether the tty has something to read before the
|
||||||
r.csi("6n")
|
// deadline. A terminal that does not recognize a query answers nothing at all,
|
||||||
r.flush()
|
// so the read that follows must be able to stop waiting, or fzf would wait for
|
||||||
var err error
|
// the user to press a key instead of drawing itself. The timeout is generous
|
||||||
bytes := []byte{}
|
// because a terminal that does answer exceeds it only when the link is slow
|
||||||
for tries := range offsetPollTries {
|
// enough to be unusable anyway.
|
||||||
bytes, _, err = r.getBytesInternal(false, bytes, tries > 0)
|
func (r *LightRenderer) waitReadable(timeout time.Duration) bool {
|
||||||
|
fd := r.fd()
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for {
|
||||||
|
remaining := time.Until(deadline)
|
||||||
|
if remaining <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var rfds unix.FdSet
|
||||||
|
if fd >= len(rfds.Bits)*unix.NFDBITS {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
rfds.Set(fd)
|
||||||
|
// Recomputed each time round: Linux select rewrites the timeout with
|
||||||
|
// the time left, other systems leave it alone
|
||||||
|
tv := unix.NsecToTimeval(int64(remaining))
|
||||||
|
n, err := unix.Select(fd+1, &rfds, nil, nil, &tv)
|
||||||
|
if err == syscall.EINTR {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return -1, -1
|
// Nothing was confirmed readable, and the read that follows
|
||||||
|
// reports the failure if the fd is really broken
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return n > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryTerminal sends every query in a single write and reads until the last
|
||||||
|
// one is answered. Returns the submatches of each reply, nil for a query the
|
||||||
|
// terminal ignored. Replies are cut out of what we read as they are recognized,
|
||||||
|
// so whatever is left over is input the user typed during the round trip.
|
||||||
|
func (r *LightRenderer) queryTerminal(queries []termQuery) [][][]byte {
|
||||||
|
for _, query := range queries {
|
||||||
|
r.csi(query.seq)
|
||||||
|
}
|
||||||
|
r.flush()
|
||||||
|
|
||||||
|
replies := make([][][]byte, len(queries))
|
||||||
|
buffer := []byte{}
|
||||||
|
for tries := range offsetPollTries {
|
||||||
|
// Only the first read blocks, so that is the one to put a bound on
|
||||||
|
if tries == 0 && !r.waitReadable(queryTimeout) {
|
||||||
|
return replies
|
||||||
}
|
}
|
||||||
|
|
||||||
offsets := offsetRegexp.FindSubmatch(bytes)
|
var err error
|
||||||
if len(offsets) > 3 {
|
buffer, _, err = r.getBytesInternal(false, buffer, tries > 0)
|
||||||
// Add anything we skipped over to the input buffer
|
if err != nil {
|
||||||
r.buffer = append(r.buffer, offsets[1]...)
|
return replies
|
||||||
return atoi(string(offsets[2]), 0) - 1, atoi(string(offsets[3]), 0) - 1
|
}
|
||||||
|
|
||||||
|
for idx, query := range queries {
|
||||||
|
if replies[idx] != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
loc := query.reply.FindSubmatchIndex(buffer)
|
||||||
|
if loc == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
groups := make([][]byte, len(loc)/2)
|
||||||
|
for group := range groups {
|
||||||
|
if loc[group*2] >= 0 {
|
||||||
|
groups[group] = buffer[loc[group*2]:loc[group*2+1]]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
replies[idx] = groups
|
||||||
|
// Capping the prefix makes append copy, leaving groups valid
|
||||||
|
buffer = append(buffer[:loc[0]:loc[0]], buffer[loc[1]:]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if replies[len(queries)-1] != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r.buffer = append(r.buffer, buffer...)
|
||||||
|
return replies
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LightRenderer) queryStartup() (row int, col int, pasteWasSet *bool) {
|
||||||
|
replies := r.queryTerminal(startupQueries)
|
||||||
|
before, paste, after := replies[0], replies[1], replies[2]
|
||||||
|
|
||||||
|
if paste != nil && paste[1][0] != '0' {
|
||||||
|
// 1 = set, 3 = permanently set
|
||||||
|
set := paste[1][0] == '1' || paste[1][0] == '3'
|
||||||
|
pasteWasSet = &set
|
||||||
|
}
|
||||||
|
row, col = parseOffset(before)
|
||||||
|
|
||||||
|
// The cursor moved right likely because terminal doesn't support DECRQM
|
||||||
|
if row2, col2 := parseOffset(after); row >= 0 && row2 == row && col2 > col {
|
||||||
|
r.flushRaw(strings.Repeat("\b \b", col2-col))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOffset(reply [][]byte) (row int, col int) {
|
||||||
|
if reply == nil {
|
||||||
return -1, -1
|
return -1, -1
|
||||||
|
}
|
||||||
|
return atoi(string(reply[1]), 0) - 1, atoi(string(reply[2]), 0) - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LightRenderer) findOffset() (row int, col int) {
|
||||||
|
return parseOffset(r.queryTerminal([]termQuery{offsetQuery})[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *LightRenderer) getch(cancellable bool, nonblock bool) (int, getCharResult) {
|
func (r *LightRenderer) getch(cancellable bool, nonblock bool) (int, getCharResult) {
|
||||||
|
|||||||
@@ -151,6 +151,13 @@ func (r *LightRenderer) findOffset() (row int, col int) {
|
|||||||
return int(bufferInfo.CursorPosition.Y), int(bufferInfo.CursorPosition.X)
|
return int(bufferInfo.CursorPosition.Y), int(bufferInfo.CursorPosition.X)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The console API answers for the cursor, and there is no reply to parse for
|
||||||
|
// bracketed paste, so fzf keeps disabling the mode on exit here.
|
||||||
|
func (r *LightRenderer) queryStartup() (row int, col int, pasteWasSet *bool) {
|
||||||
|
row, col = r.findOffset()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
func (r *LightRenderer) getch(cancellable bool, nonblock bool) (int, getCharResult) {
|
func (r *LightRenderer) getch(cancellable bool, nonblock bool) (int, getCharResult) {
|
||||||
if !nonblock && !cancellable {
|
if !nonblock && !cancellable {
|
||||||
bc := <-r.ttyinChannel
|
bc := <-r.ttyinChannel
|
||||||
|
|||||||
+135
-54
@@ -1017,6 +1017,115 @@ func (w *TcellWindow) DrawHBorder() {
|
|||||||
w.drawBorder(true)
|
w.drawBorder(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// borderStyleFor returns the tcell.Style used to draw borders for `wt`, honoring
|
||||||
|
// whether the window is rendering with colors.
|
||||||
|
func (w *TcellWindow) borderStyleFor(wt WindowType) tcell.Style {
|
||||||
|
if !w.color {
|
||||||
|
return w.normal.style()
|
||||||
|
}
|
||||||
|
return BorderColor(wt).style()
|
||||||
|
}
|
||||||
|
|
||||||
|
// drawHLine fills row `y` with `line` between optional left/right caps.
|
||||||
|
// A zero rune means "no cap"; caps are placed at the very edges of `w`.
|
||||||
|
// tcell has an issue displaying two overlapping wide runes, so the line
|
||||||
|
// stops before the cap position rather than overpainting.
|
||||||
|
func (w *TcellWindow) drawHLine(y int, line, leftCap, rightCap rune, style tcell.Style) {
|
||||||
|
left := w.left
|
||||||
|
right := left + w.width
|
||||||
|
hw := runeWidth(line)
|
||||||
|
lw := 0
|
||||||
|
rw := 0
|
||||||
|
if leftCap != 0 {
|
||||||
|
lw = runeWidth(leftCap)
|
||||||
|
}
|
||||||
|
if rightCap != 0 {
|
||||||
|
rw = runeWidth(rightCap)
|
||||||
|
}
|
||||||
|
for x := left + lw; x <= right-rw-hw; x += hw {
|
||||||
|
_screen.SetContent(x, y, line, nil, style)
|
||||||
|
}
|
||||||
|
if leftCap != 0 {
|
||||||
|
_screen.SetContent(left, y, leftCap, nil, style)
|
||||||
|
}
|
||||||
|
if rightCap != 0 {
|
||||||
|
_screen.SetContent(right-rw, y, rightCap, nil, style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *TcellWindow) DrawHSeparator(row int, windowType WindowType, useBottom bool) {
|
||||||
|
if w.height == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
shape := w.borderStyle.shape
|
||||||
|
if shape == BorderNone {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
style := w.borderStyleFor(windowType)
|
||||||
|
line := w.borderStyle.top
|
||||||
|
if useBottom {
|
||||||
|
line = w.borderStyle.bottom
|
||||||
|
}
|
||||||
|
var leftCap, rightCap rune
|
||||||
|
if shape.HasLeft() {
|
||||||
|
leftCap = w.borderStyle.leftMid
|
||||||
|
}
|
||||||
|
if shape.HasRight() {
|
||||||
|
rightCap = w.borderStyle.rightMid
|
||||||
|
}
|
||||||
|
w.drawHLine(w.top+row, line, leftCap, rightCap, style)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *TcellWindow) PaintSectionFrame(topContent, bottomContent int, windowType WindowType, edge SectionEdge) {
|
||||||
|
if w.height == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
shape := w.borderStyle.shape
|
||||||
|
if shape == BorderNone {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
style := w.borderStyleFor(windowType)
|
||||||
|
left := w.left
|
||||||
|
right := left + w.width
|
||||||
|
hasLeft := shape.HasLeft()
|
||||||
|
hasRight := shape.HasRight()
|
||||||
|
leftW := runeWidth(w.borderStyle.left)
|
||||||
|
rightW := runeWidth(w.borderStyle.right)
|
||||||
|
// Content rows: overpaint the left and right verticals (+ their 1-char margin) in
|
||||||
|
// the section's color. Inner margin stays at whatever bg the sub-window set.
|
||||||
|
for row := topContent; row <= bottomContent; row++ {
|
||||||
|
y := w.top + row
|
||||||
|
if hasLeft {
|
||||||
|
_screen.SetContent(left, y, w.borderStyle.left, nil, style)
|
||||||
|
_screen.SetContent(left+leftW, y, ' ', nil, style)
|
||||||
|
}
|
||||||
|
if hasRight {
|
||||||
|
_screen.SetContent(right-rightW-1, y, ' ', nil, style)
|
||||||
|
_screen.SetContent(right-rightW, y, w.borderStyle.right, nil, style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if edge == SectionEdgeTop && shape.HasTop() {
|
||||||
|
var leftCap, rightCap rune
|
||||||
|
if hasLeft {
|
||||||
|
leftCap = w.borderStyle.topLeft
|
||||||
|
}
|
||||||
|
if hasRight {
|
||||||
|
rightCap = w.borderStyle.topRight
|
||||||
|
}
|
||||||
|
w.drawHLine(w.top, w.borderStyle.top, leftCap, rightCap, style)
|
||||||
|
}
|
||||||
|
if edge == SectionEdgeBottom && shape.HasBottom() {
|
||||||
|
var leftCap, rightCap rune
|
||||||
|
if hasLeft {
|
||||||
|
leftCap = w.borderStyle.bottomLeft
|
||||||
|
}
|
||||||
|
if hasRight {
|
||||||
|
rightCap = w.borderStyle.bottomRight
|
||||||
|
}
|
||||||
|
w.drawHLine(w.top+w.height-1, w.borderStyle.bottom, leftCap, rightCap, style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (w *TcellWindow) drawBorder(onlyHorizontal bool) {
|
func (w *TcellWindow) drawBorder(onlyHorizontal bool) {
|
||||||
if w.height == 0 {
|
if w.height == 0 {
|
||||||
return
|
return
|
||||||
@@ -1031,72 +1140,44 @@ func (w *TcellWindow) drawBorder(onlyHorizontal bool) {
|
|||||||
top := w.top
|
top := w.top
|
||||||
bot := top + w.height
|
bot := top + w.height
|
||||||
|
|
||||||
var style tcell.Style
|
style := w.borderStyleFor(w.windowType)
|
||||||
if w.color {
|
|
||||||
switch w.windowType {
|
|
||||||
case WindowBase:
|
|
||||||
style = ColBorder.style()
|
|
||||||
case WindowList:
|
|
||||||
style = ColListBorder.style()
|
|
||||||
case WindowHeader:
|
|
||||||
style = ColHeaderBorder.style()
|
|
||||||
case WindowFooter:
|
|
||||||
style = ColFooterBorder.style()
|
|
||||||
case WindowInput:
|
|
||||||
style = ColInputBorder.style()
|
|
||||||
case WindowPreview:
|
|
||||||
style = ColPreviewBorder.style()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
style = w.normal.style()
|
|
||||||
}
|
|
||||||
|
|
||||||
hw := runeWidth(w.borderStyle.top)
|
hasLeft := shape.HasLeft()
|
||||||
switch shape {
|
hasRight := shape.HasRight()
|
||||||
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble, BorderHorizontal, BorderTop:
|
|
||||||
max := right - 2*hw
|
if shape.HasTop() {
|
||||||
if shape == BorderHorizontal || shape == BorderTop {
|
var leftCap, rightCap rune
|
||||||
max = right - hw
|
if hasLeft {
|
||||||
|
leftCap = w.borderStyle.topLeft
|
||||||
}
|
}
|
||||||
// tcell has an issue displaying two overlapping wide runes
|
if hasRight {
|
||||||
// e.g. SetContent( HH )
|
rightCap = w.borderStyle.topRight
|
||||||
// SetContent( TR )
|
|
||||||
// ==================
|
|
||||||
// ( HH ) => TR is ignored
|
|
||||||
for x := left; x <= max; x += hw {
|
|
||||||
_screen.SetContent(x, top, w.borderStyle.top, nil, style)
|
|
||||||
}
|
}
|
||||||
|
w.drawHLine(top, w.borderStyle.top, leftCap, rightCap, style)
|
||||||
}
|
}
|
||||||
switch shape {
|
if shape.HasBottom() {
|
||||||
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble, BorderHorizontal, BorderBottom:
|
var leftCap, rightCap rune
|
||||||
max := right - 2*hw
|
if hasLeft {
|
||||||
if shape == BorderHorizontal || shape == BorderBottom {
|
leftCap = w.borderStyle.bottomLeft
|
||||||
max = right - hw
|
|
||||||
}
|
}
|
||||||
for x := left; x <= max; x += hw {
|
if hasRight {
|
||||||
_screen.SetContent(x, bot-1, w.borderStyle.bottom, nil, style)
|
rightCap = w.borderStyle.bottomRight
|
||||||
}
|
}
|
||||||
|
w.drawHLine(bot-1, w.borderStyle.bottom, leftCap, rightCap, style)
|
||||||
}
|
}
|
||||||
if !onlyHorizontal {
|
if !onlyHorizontal {
|
||||||
switch shape {
|
|
||||||
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble, BorderVertical, BorderLeft:
|
|
||||||
for y := top; y < bot; y++ {
|
|
||||||
_screen.SetContent(left, y, w.borderStyle.left, nil, style)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
switch shape {
|
|
||||||
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble, BorderVertical, BorderRight:
|
|
||||||
vw := runeWidth(w.borderStyle.right)
|
vw := runeWidth(w.borderStyle.right)
|
||||||
for y := top; y < bot; y++ {
|
for y := top; y < bot; y++ {
|
||||||
|
// Corner rows are already painted by drawHLine above / below.
|
||||||
|
if (y == top && shape.HasTop()) || (y == bot-1 && shape.HasBottom()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if hasLeft {
|
||||||
|
_screen.SetContent(left, y, w.borderStyle.left, nil, style)
|
||||||
|
}
|
||||||
|
if hasRight {
|
||||||
_screen.SetContent(right-vw, y, w.borderStyle.right, nil, style)
|
_screen.SetContent(right-vw, y, w.borderStyle.right, nil, style)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
switch shape {
|
|
||||||
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble:
|
|
||||||
_screen.SetContent(left, top, w.borderStyle.topLeft, nil, style)
|
|
||||||
_screen.SetContent(right-runeWidth(w.borderStyle.topRight), top, w.borderStyle.topRight, nil, style)
|
|
||||||
_screen.SetContent(left, bot-1, w.borderStyle.bottomLeft, nil, style)
|
|
||||||
_screen.SetContent(right-runeWidth(w.borderStyle.bottomRight), bot-1, w.borderStyle.bottomRight, nil, style)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+190
-71
@@ -4,6 +4,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"github.com/junegunn/fzf/src/util"
|
"github.com/junegunn/fzf/src/util"
|
||||||
"github.com/rivo/uniseg"
|
"github.com/rivo/uniseg"
|
||||||
@@ -196,11 +197,6 @@ const (
|
|||||||
CtrlAltShiftPageUp
|
CtrlAltShiftPageUp
|
||||||
CtrlAltShiftPageDown
|
CtrlAltShiftPageDown
|
||||||
|
|
||||||
Invalid
|
|
||||||
Fatal
|
|
||||||
BracketedPasteBegin
|
|
||||||
BracketedPasteEnd
|
|
||||||
|
|
||||||
Mouse
|
Mouse
|
||||||
DoubleClick
|
DoubleClick
|
||||||
LeftClick
|
LeftClick
|
||||||
@@ -214,7 +210,15 @@ const (
|
|||||||
PreviewScrollUp
|
PreviewScrollUp
|
||||||
PreviewScrollDown
|
PreviewScrollDown
|
||||||
|
|
||||||
// Events
|
// Synthetic / non-user events. Everything from Invalid onward is
|
||||||
|
// either internally generated or a state-change notification, not
|
||||||
|
// direct user input. Use `>= Invalid` to gate activity tracking.
|
||||||
|
// BracketedPasteBegin/End sit here too: they enclose user input
|
||||||
|
// (which arrives as Rune events) and should not appear in FZF_KEY.
|
||||||
|
Invalid
|
||||||
|
Fatal
|
||||||
|
BracketedPasteBegin
|
||||||
|
BracketedPasteEnd
|
||||||
Resize
|
Resize
|
||||||
Change
|
Change
|
||||||
BackwardEOF
|
BackwardEOF
|
||||||
@@ -229,6 +233,8 @@ const (
|
|||||||
ClickHeader
|
ClickHeader
|
||||||
ClickFooter
|
ClickFooter
|
||||||
Multi
|
Multi
|
||||||
|
Every
|
||||||
|
ResultFinal
|
||||||
)
|
)
|
||||||
|
|
||||||
func (t EventType) AsEvent() Event {
|
func (t EventType) AsEvent() Event {
|
||||||
@@ -248,6 +254,12 @@ func (e Event) Comparable() Event {
|
|||||||
return Event{e.Type, e.Char, nil}
|
return Event{e.Type, e.Char, nil}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Printable returns true if the event is a printable character that can be
|
||||||
|
// inserted into the query (e.g. via the 'put' action).
|
||||||
|
func (e Event) Printable() bool {
|
||||||
|
return e.Type == Rune && unicode.IsGraphic(e.Char)
|
||||||
|
}
|
||||||
|
|
||||||
func (e Event) KeyName() string {
|
func (e Event) KeyName() string {
|
||||||
if me := e.MouseEvent; me != nil {
|
if me := e.MouseEvent; me != nil {
|
||||||
return me.Name()
|
return me.Name()
|
||||||
@@ -447,6 +459,12 @@ func (p ColorPair) WithAttr(attr Attr) ColorPair {
|
|||||||
return dup
|
return dup
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p ColorPair) WithNewAttr(attr Attr) ColorPair {
|
||||||
|
dup := p
|
||||||
|
dup.attr = attr
|
||||||
|
return dup
|
||||||
|
}
|
||||||
|
|
||||||
func (p ColorPair) WithFg(fg ColorAttr) ColorPair {
|
func (p ColorPair) WithFg(fg ColorAttr) ColorPair {
|
||||||
dup := p
|
dup := p
|
||||||
fgPair := ColorPair{fg.Color, colUndefined, colUndefined, fg.Attr}
|
fgPair := ColorPair{fg.Color, colUndefined, colUndefined, fg.Attr}
|
||||||
@@ -500,7 +518,7 @@ type ColorTheme struct {
|
|||||||
CurrentMatch ColorAttr
|
CurrentMatch ColorAttr
|
||||||
Spinner ColorAttr
|
Spinner ColorAttr
|
||||||
Info ColorAttr
|
Info ColorAttr
|
||||||
Cursor ColorAttr
|
Pointer ColorAttr
|
||||||
Marker ColorAttr
|
Marker ColorAttr
|
||||||
Header ColorAttr
|
Header ColorAttr
|
||||||
HeaderBg ColorAttr
|
HeaderBg ColorAttr
|
||||||
@@ -520,6 +538,8 @@ type ColorTheme struct {
|
|||||||
ListLabel ColorAttr
|
ListLabel ColorAttr
|
||||||
ListBorder ColorAttr
|
ListBorder ColorAttr
|
||||||
GapLine ColorAttr
|
GapLine ColorAttr
|
||||||
|
NthCurrentAttr Attr // raw current-fg attr (before fg merge) for nth overlay
|
||||||
|
NthSelectedAttr Attr // raw selected-fg attr (before ListFg inherit) for nth overlay
|
||||||
}
|
}
|
||||||
|
|
||||||
type Event struct {
|
type Event struct {
|
||||||
@@ -587,11 +607,13 @@ const (
|
|||||||
BorderBottom
|
BorderBottom
|
||||||
BorderLeft
|
BorderLeft
|
||||||
BorderRight
|
BorderRight
|
||||||
|
BorderInline
|
||||||
|
BorderDashed
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s BorderShape) HasLeft() bool {
|
func (s BorderShape) HasLeft() bool {
|
||||||
switch s {
|
switch s {
|
||||||
case BorderNone, BorderPhantom, BorderLine, BorderRight, BorderTop, BorderBottom, BorderHorizontal: // No Left
|
case BorderNone, BorderPhantom, BorderLine, BorderInline, BorderRight, BorderTop, BorderBottom, BorderHorizontal: // No Left
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -599,7 +621,7 @@ func (s BorderShape) HasLeft() bool {
|
|||||||
|
|
||||||
func (s BorderShape) HasRight() bool {
|
func (s BorderShape) HasRight() bool {
|
||||||
switch s {
|
switch s {
|
||||||
case BorderNone, BorderPhantom, BorderLine, BorderLeft, BorderTop, BorderBottom, BorderHorizontal: // No right
|
case BorderNone, BorderPhantom, BorderLine, BorderInline, BorderLeft, BorderTop, BorderBottom, BorderHorizontal: // No right
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -607,7 +629,7 @@ func (s BorderShape) HasRight() bool {
|
|||||||
|
|
||||||
func (s BorderShape) HasTop() bool {
|
func (s BorderShape) HasTop() bool {
|
||||||
switch s {
|
switch s {
|
||||||
case BorderNone, BorderPhantom, BorderLine, BorderLeft, BorderRight, BorderBottom, BorderVertical: // No top
|
case BorderNone, BorderPhantom, BorderLine, BorderInline, BorderLeft, BorderRight, BorderBottom, BorderVertical: // No top
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -615,7 +637,7 @@ func (s BorderShape) HasTop() bool {
|
|||||||
|
|
||||||
func (s BorderShape) HasBottom() bool {
|
func (s BorderShape) HasBottom() bool {
|
||||||
switch s {
|
switch s {
|
||||||
case BorderNone, BorderPhantom, BorderLine, BorderLeft, BorderRight, BorderTop, BorderVertical: // No bottom
|
case BorderNone, BorderPhantom, BorderLine, BorderInline, BorderLeft, BorderRight, BorderTop, BorderVertical: // No bottom
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -635,6 +657,8 @@ type BorderStyle struct {
|
|||||||
topRight rune
|
topRight rune
|
||||||
bottomLeft rune
|
bottomLeft rune
|
||||||
bottomRight rune
|
bottomRight rune
|
||||||
|
leftMid rune
|
||||||
|
rightMid rune
|
||||||
}
|
}
|
||||||
|
|
||||||
type BorderCharacter int
|
type BorderCharacter int
|
||||||
@@ -650,7 +674,9 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
|
|||||||
topLeft: ' ',
|
topLeft: ' ',
|
||||||
topRight: ' ',
|
topRight: ' ',
|
||||||
bottomLeft: ' ',
|
bottomLeft: ' ',
|
||||||
bottomRight: ' '}
|
bottomRight: ' ',
|
||||||
|
leftMid: ' ',
|
||||||
|
rightMid: ' '}
|
||||||
}
|
}
|
||||||
if !unicode {
|
if !unicode {
|
||||||
return BorderStyle{
|
return BorderStyle{
|
||||||
@@ -663,6 +689,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
|
|||||||
topRight: '+',
|
topRight: '+',
|
||||||
bottomLeft: '+',
|
bottomLeft: '+',
|
||||||
bottomRight: '+',
|
bottomRight: '+',
|
||||||
|
leftMid: '+',
|
||||||
|
rightMid: '+',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
switch shape {
|
switch shape {
|
||||||
@@ -677,6 +705,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
|
|||||||
topRight: '┐',
|
topRight: '┐',
|
||||||
bottomLeft: '└',
|
bottomLeft: '└',
|
||||||
bottomRight: '┘',
|
bottomRight: '┘',
|
||||||
|
leftMid: '├',
|
||||||
|
rightMid: '┤',
|
||||||
}
|
}
|
||||||
case BorderBold:
|
case BorderBold:
|
||||||
return BorderStyle{
|
return BorderStyle{
|
||||||
@@ -689,6 +719,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
|
|||||||
topRight: '┓',
|
topRight: '┓',
|
||||||
bottomLeft: '┗',
|
bottomLeft: '┗',
|
||||||
bottomRight: '┛',
|
bottomRight: '┛',
|
||||||
|
leftMid: '┣',
|
||||||
|
rightMid: '┫',
|
||||||
}
|
}
|
||||||
case BorderBlock:
|
case BorderBlock:
|
||||||
// ▛▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▜
|
// ▛▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▜
|
||||||
@@ -704,6 +736,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
|
|||||||
topRight: '▜',
|
topRight: '▜',
|
||||||
bottomLeft: '▙',
|
bottomLeft: '▙',
|
||||||
bottomRight: '▟',
|
bottomRight: '▟',
|
||||||
|
leftMid: '▌',
|
||||||
|
rightMid: '▐',
|
||||||
}
|
}
|
||||||
|
|
||||||
case BorderThinBlock:
|
case BorderThinBlock:
|
||||||
@@ -720,6 +754,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
|
|||||||
topRight: '🭾',
|
topRight: '🭾',
|
||||||
bottomLeft: '🭼',
|
bottomLeft: '🭼',
|
||||||
bottomRight: '🭿',
|
bottomRight: '🭿',
|
||||||
|
leftMid: '▏',
|
||||||
|
rightMid: '▕',
|
||||||
}
|
}
|
||||||
|
|
||||||
case BorderDouble:
|
case BorderDouble:
|
||||||
@@ -733,6 +769,25 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
|
|||||||
topRight: '╗',
|
topRight: '╗',
|
||||||
bottomLeft: '╚',
|
bottomLeft: '╚',
|
||||||
bottomRight: '╝',
|
bottomRight: '╝',
|
||||||
|
leftMid: '╠',
|
||||||
|
rightMid: '╣',
|
||||||
|
}
|
||||||
|
case BorderDashed:
|
||||||
|
// Terminal cells are taller than wide (~2:1), so horizontals can use a
|
||||||
|
// sparse stub per cell while verticals need more dashes per cell to look
|
||||||
|
// evenly dashed. Rounded corners and sharp T-junction mids.
|
||||||
|
return BorderStyle{
|
||||||
|
shape: shape,
|
||||||
|
top: '╶',
|
||||||
|
bottom: '╶',
|
||||||
|
left: '┆',
|
||||||
|
right: '┆',
|
||||||
|
topLeft: '╭',
|
||||||
|
topRight: '╮',
|
||||||
|
bottomLeft: '╰',
|
||||||
|
bottomRight: '╯',
|
||||||
|
leftMid: '├',
|
||||||
|
rightMid: '┤',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return BorderStyle{
|
return BorderStyle{
|
||||||
@@ -745,6 +800,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
|
|||||||
topRight: '╮',
|
topRight: '╮',
|
||||||
bottomLeft: '╰',
|
bottomLeft: '╰',
|
||||||
bottomRight: '╯',
|
bottomRight: '╯',
|
||||||
|
leftMid: '├',
|
||||||
|
rightMid: '┤',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -766,6 +823,35 @@ const (
|
|||||||
WindowFooter
|
WindowFooter
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// BorderColor returns the ColorPair used to draw borders for the given WindowType.
|
||||||
|
func BorderColor(wt WindowType) ColorPair {
|
||||||
|
switch wt {
|
||||||
|
case WindowList:
|
||||||
|
return ColListBorder
|
||||||
|
case WindowInput:
|
||||||
|
return ColInputBorder
|
||||||
|
case WindowHeader:
|
||||||
|
return ColHeaderBorder
|
||||||
|
case WindowFooter:
|
||||||
|
return ColFooterBorder
|
||||||
|
case WindowPreview:
|
||||||
|
return ColPreviewBorder
|
||||||
|
}
|
||||||
|
return ColBorder
|
||||||
|
}
|
||||||
|
|
||||||
|
// SectionEdge selects which outer edge of the frame an inline section
|
||||||
|
// should claim when PaintSectionFrame overpaints its adjacent border.
|
||||||
|
// SectionEdgeNone paints only the inner verticals (for sections that
|
||||||
|
// don't touch the outer top or bottom).
|
||||||
|
type SectionEdge int
|
||||||
|
|
||||||
|
const (
|
||||||
|
SectionEdgeNone SectionEdge = iota
|
||||||
|
SectionEdgeTop
|
||||||
|
SectionEdgeBottom
|
||||||
|
)
|
||||||
|
|
||||||
type Renderer interface {
|
type Renderer interface {
|
||||||
DefaultTheme() *ColorTheme
|
DefaultTheme() *ColorTheme
|
||||||
Init() error
|
Init() error
|
||||||
@@ -803,6 +889,19 @@ type Window interface {
|
|||||||
|
|
||||||
DrawBorder()
|
DrawBorder()
|
||||||
DrawHBorder()
|
DrawHBorder()
|
||||||
|
// DrawHSeparator draws an inline horizontal separator at `row` (relative to the
|
||||||
|
// window's top) using the color for `windowType`. The separator is conceptually
|
||||||
|
// the section's inner edge (e.g. the bottom border of an inline header), so the
|
||||||
|
// whole row including junctions carries the section's fg + bg. When useBottom is
|
||||||
|
// true the `bottom` horizontal char is used instead of `top`; for thinblock/block
|
||||||
|
// styles this keeps the thin line bonded to the list content on the opposite side.
|
||||||
|
DrawHSeparator(row int, windowType WindowType, useBottom bool)
|
||||||
|
// PaintSectionFrame overpaints the border cells around the rows [topContent,
|
||||||
|
// bottomContent] (inclusive, relative to the window's top) with the color for
|
||||||
|
// `windowType`. When edge is SectionEdgeTop / SectionEdgeBottom, the
|
||||||
|
// corresponding outer horizontal (+ corners) is also painted, letting the
|
||||||
|
// inline section claim that edge of the outer frame.
|
||||||
|
PaintSectionFrame(topContent, bottomContent int, windowType WindowType, edge SectionEdge)
|
||||||
Refresh()
|
Refresh()
|
||||||
FinishFill()
|
FinishFill()
|
||||||
|
|
||||||
@@ -861,18 +960,18 @@ var (
|
|||||||
ColDisabled ColorPair
|
ColDisabled ColorPair
|
||||||
ColGhost ColorPair
|
ColGhost ColorPair
|
||||||
ColMatch ColorPair
|
ColMatch ColorPair
|
||||||
ColCursor ColorPair
|
ColPointer ColorPair
|
||||||
ColCursorEmpty ColorPair
|
ColPointerEmpty ColorPair
|
||||||
ColCursorEmptyChar ColorPair
|
ColPointerEmptyChar ColorPair
|
||||||
ColAltCursorEmpty ColorPair
|
ColAltPointerEmpty ColorPair
|
||||||
ColAltCursorEmptyChar ColorPair
|
ColAltPointerEmptyChar ColorPair
|
||||||
ColMarker ColorPair
|
ColMarker ColorPair
|
||||||
ColSelected ColorPair
|
ColSelected ColorPair
|
||||||
ColSelectedMatch ColorPair
|
ColSelectedMatch ColorPair
|
||||||
ColCurrent ColorPair
|
ColCurrent ColorPair
|
||||||
ColCurrentMatch ColorPair
|
ColCurrentMatch ColorPair
|
||||||
ColCurrentCursor ColorPair
|
ColCurrentPointer ColorPair
|
||||||
ColCurrentCursorEmpty ColorPair
|
ColCurrentPointerEmpty ColorPair
|
||||||
ColCurrentMarker ColorPair
|
ColCurrentMarker ColorPair
|
||||||
ColCurrentSelectedEmpty ColorPair
|
ColCurrentSelectedEmpty ColorPair
|
||||||
ColSpinner ColorPair
|
ColSpinner ColorPair
|
||||||
@@ -905,50 +1004,55 @@ func init() {
|
|||||||
|
|
||||||
NoColorTheme = &ColorTheme{
|
NoColorTheme = &ColorTheme{
|
||||||
Colored: false,
|
Colored: false,
|
||||||
|
// Root colors. Everything else is left undefined so that overriding a
|
||||||
|
// root (e.g. --color bw,bg:blue) propagates to the derived colors,
|
||||||
|
// just like in the colored base themes.
|
||||||
Input: defaultColor,
|
Input: defaultColor,
|
||||||
Fg: defaultColor,
|
Fg: defaultColor,
|
||||||
Bg: defaultColor,
|
Bg: defaultColor,
|
||||||
ListFg: defaultColor,
|
|
||||||
ListBg: defaultColor,
|
|
||||||
AltBg: undefined,
|
|
||||||
SelectedFg: defaultColor,
|
|
||||||
SelectedBg: defaultColor,
|
|
||||||
SelectedMatch: defaultColor,
|
|
||||||
DarkBg: defaultColor,
|
DarkBg: defaultColor,
|
||||||
Prompt: defaultColor,
|
Prompt: defaultColor,
|
||||||
Match: defaultColor,
|
Match: defaultColor,
|
||||||
Current: undefined,
|
|
||||||
CurrentMatch: undefined,
|
|
||||||
Spinner: defaultColor,
|
Spinner: defaultColor,
|
||||||
Info: defaultColor,
|
Info: defaultColor,
|
||||||
Cursor: defaultColor,
|
Pointer: defaultColor,
|
||||||
Marker: defaultColor,
|
Marker: defaultColor,
|
||||||
Header: defaultColor,
|
Header: defaultColor,
|
||||||
Border: undefined,
|
Footer: defaultColor,
|
||||||
BorderLabel: defaultColor,
|
BorderLabel: defaultColor,
|
||||||
|
// Derived colors. Left undefined so they inherit from a root.
|
||||||
|
ListFg: undefined,
|
||||||
|
ListBg: undefined,
|
||||||
|
AltBg: undefined,
|
||||||
|
SelectedFg: undefined,
|
||||||
|
SelectedBg: undefined,
|
||||||
|
SelectedMatch: undefined,
|
||||||
|
Current: undefined,
|
||||||
|
CurrentMatch: undefined,
|
||||||
|
Border: undefined,
|
||||||
Ghost: undefined,
|
Ghost: undefined,
|
||||||
Disabled: defaultColor,
|
Disabled: undefined,
|
||||||
PreviewFg: defaultColor,
|
PreviewFg: undefined,
|
||||||
PreviewBg: defaultColor,
|
PreviewBg: undefined,
|
||||||
Gutter: undefined,
|
Gutter: undefined,
|
||||||
AltGutter: undefined,
|
AltGutter: undefined,
|
||||||
PreviewBorder: defaultColor,
|
PreviewBorder: undefined,
|
||||||
PreviewScrollbar: defaultColor,
|
PreviewScrollbar: undefined,
|
||||||
PreviewLabel: defaultColor,
|
PreviewLabel: undefined,
|
||||||
ListLabel: defaultColor,
|
ListLabel: undefined,
|
||||||
ListBorder: defaultColor,
|
ListBorder: undefined,
|
||||||
Separator: defaultColor,
|
Separator: undefined,
|
||||||
Scrollbar: defaultColor,
|
Scrollbar: undefined,
|
||||||
InputBg: defaultColor,
|
InputBg: undefined,
|
||||||
InputBorder: defaultColor,
|
InputBorder: undefined,
|
||||||
InputLabel: defaultColor,
|
InputLabel: undefined,
|
||||||
HeaderBg: defaultColor,
|
HeaderBg: undefined,
|
||||||
HeaderBorder: defaultColor,
|
HeaderBorder: undefined,
|
||||||
HeaderLabel: defaultColor,
|
HeaderLabel: undefined,
|
||||||
FooterBg: defaultColor,
|
FooterBg: undefined,
|
||||||
FooterBorder: defaultColor,
|
FooterBorder: undefined,
|
||||||
FooterLabel: defaultColor,
|
FooterLabel: undefined,
|
||||||
GapLine: defaultColor,
|
GapLine: undefined,
|
||||||
Nth: undefined,
|
Nth: undefined,
|
||||||
Nomatch: undefined,
|
Nomatch: undefined,
|
||||||
}
|
}
|
||||||
@@ -971,7 +1075,7 @@ func init() {
|
|||||||
CurrentMatch: undefined,
|
CurrentMatch: undefined,
|
||||||
Spinner: undefined,
|
Spinner: undefined,
|
||||||
Info: undefined,
|
Info: undefined,
|
||||||
Cursor: undefined,
|
Pointer: undefined,
|
||||||
Marker: undefined,
|
Marker: undefined,
|
||||||
Header: undefined,
|
Header: undefined,
|
||||||
Footer: undefined,
|
Footer: undefined,
|
||||||
@@ -1022,7 +1126,7 @@ func init() {
|
|||||||
CurrentMatch: ColorAttr{colBrightGreen, AttrUndefined},
|
CurrentMatch: ColorAttr{colBrightGreen, AttrUndefined},
|
||||||
Spinner: ColorAttr{colGreen, AttrUndefined},
|
Spinner: ColorAttr{colGreen, AttrUndefined},
|
||||||
Info: ColorAttr{colYellow, AttrUndefined},
|
Info: ColorAttr{colYellow, AttrUndefined},
|
||||||
Cursor: ColorAttr{colRed, AttrUndefined},
|
Pointer: ColorAttr{colRed, AttrUndefined},
|
||||||
Marker: ColorAttr{colMagenta, AttrUndefined},
|
Marker: ColorAttr{colMagenta, AttrUndefined},
|
||||||
Header: ColorAttr{colCyan, AttrUndefined},
|
Header: ColorAttr{colCyan, AttrUndefined},
|
||||||
Footer: ColorAttr{colCyan, AttrUndefined},
|
Footer: ColorAttr{colCyan, AttrUndefined},
|
||||||
@@ -1073,7 +1177,7 @@ func init() {
|
|||||||
CurrentMatch: ColorAttr{151, AttrUndefined},
|
CurrentMatch: ColorAttr{151, AttrUndefined},
|
||||||
Spinner: ColorAttr{148, AttrUndefined},
|
Spinner: ColorAttr{148, AttrUndefined},
|
||||||
Info: ColorAttr{144, AttrUndefined},
|
Info: ColorAttr{144, AttrUndefined},
|
||||||
Cursor: ColorAttr{161, AttrUndefined},
|
Pointer: ColorAttr{161, AttrUndefined},
|
||||||
Marker: ColorAttr{168, AttrUndefined},
|
Marker: ColorAttr{168, AttrUndefined},
|
||||||
Header: ColorAttr{109, AttrUndefined},
|
Header: ColorAttr{109, AttrUndefined},
|
||||||
Footer: ColorAttr{109, AttrUndefined},
|
Footer: ColorAttr{109, AttrUndefined},
|
||||||
@@ -1124,7 +1228,7 @@ func init() {
|
|||||||
CurrentMatch: ColorAttr{23, AttrUndefined},
|
CurrentMatch: ColorAttr{23, AttrUndefined},
|
||||||
Spinner: ColorAttr{65, AttrUndefined},
|
Spinner: ColorAttr{65, AttrUndefined},
|
||||||
Info: ColorAttr{101, AttrUndefined},
|
Info: ColorAttr{101, AttrUndefined},
|
||||||
Cursor: ColorAttr{161, AttrUndefined},
|
Pointer: ColorAttr{161, AttrUndefined},
|
||||||
Marker: ColorAttr{168, AttrUndefined},
|
Marker: ColorAttr{168, AttrUndefined},
|
||||||
Header: ColorAttr{31, AttrUndefined},
|
Header: ColorAttr{31, AttrUndefined},
|
||||||
Footer: ColorAttr{31, AttrUndefined},
|
Footer: ColorAttr{31, AttrUndefined},
|
||||||
@@ -1158,7 +1262,7 @@ func init() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlack bool, hasInputWindow bool, hasHeaderWindow bool) {
|
func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlack bool, hasInputWindow bool, hasHeaderWindow bool, headerInline bool, footerInline bool) {
|
||||||
if forceBlack {
|
if forceBlack {
|
||||||
theme.Bg = ColorAttr{colBlack, AttrUndefined}
|
theme.Bg = ColorAttr{colBlack, AttrUndefined}
|
||||||
}
|
}
|
||||||
@@ -1175,7 +1279,7 @@ func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlac
|
|||||||
theme.CurrentMatch = boldify(theme.CurrentMatch)
|
theme.CurrentMatch = boldify(theme.CurrentMatch)
|
||||||
theme.Prompt = boldify(theme.Prompt)
|
theme.Prompt = boldify(theme.Prompt)
|
||||||
theme.Input = boldify(theme.Input)
|
theme.Input = boldify(theme.Input)
|
||||||
theme.Cursor = boldify(theme.Cursor)
|
theme.Pointer = boldify(theme.Pointer)
|
||||||
theme.Spinner = boldify(theme.Spinner)
|
theme.Spinner = boldify(theme.Spinner)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1199,13 +1303,19 @@ func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlac
|
|||||||
match.Attr = Underline
|
match.Attr = Underline
|
||||||
}
|
}
|
||||||
theme.Match = o(baseTheme.Match, match)
|
theme.Match = o(baseTheme.Match, match)
|
||||||
// Inherit from 'fg', so that we don't have to write 'current-fg:dim'
|
// These colors are not defined in the base themes.
|
||||||
|
// Resolve ListFg/ListBg early so Current and Selected can inherit from them.
|
||||||
|
theme.ListFg = o(theme.Fg, theme.ListFg)
|
||||||
|
theme.ListBg = o(theme.Bg, theme.ListBg)
|
||||||
|
// Inherit from 'list-fg', so that we don't have to write 'current-fg:dim'
|
||||||
// e.g. fzf --delimiter / --nth -1 --color fg:dim,nth:regular
|
// e.g. fzf --delimiter / --nth -1 --color fg:dim,nth:regular
|
||||||
current := theme.Current
|
current := theme.Current
|
||||||
if !baseTheme.Colored && current.IsUndefined() {
|
if !baseTheme.Colored && current.IsUndefined() {
|
||||||
current.Attr |= Reverse
|
current.Attr |= Reverse
|
||||||
}
|
}
|
||||||
theme.Current = theme.Fg.Merge(o(baseTheme.Current, current))
|
resolvedCurrent := o(baseTheme.Current, current)
|
||||||
|
theme.NthCurrentAttr = resolvedCurrent.Attr
|
||||||
|
theme.Current = theme.ListFg.Merge(resolvedCurrent)
|
||||||
currentMatch := theme.CurrentMatch
|
currentMatch := theme.CurrentMatch
|
||||||
if !baseTheme.Colored && currentMatch.IsUndefined() {
|
if !baseTheme.Colored && currentMatch.IsUndefined() {
|
||||||
currentMatch.Attr |= Reverse | Underline
|
currentMatch.Attr |= Reverse | Underline
|
||||||
@@ -1213,7 +1323,7 @@ func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlac
|
|||||||
theme.CurrentMatch = o(baseTheme.CurrentMatch, currentMatch)
|
theme.CurrentMatch = o(baseTheme.CurrentMatch, currentMatch)
|
||||||
theme.Spinner = o(baseTheme.Spinner, theme.Spinner)
|
theme.Spinner = o(baseTheme.Spinner, theme.Spinner)
|
||||||
theme.Info = o(baseTheme.Info, theme.Info)
|
theme.Info = o(baseTheme.Info, theme.Info)
|
||||||
theme.Cursor = o(baseTheme.Cursor, theme.Cursor)
|
theme.Pointer = o(baseTheme.Pointer, theme.Pointer)
|
||||||
theme.Marker = o(baseTheme.Marker, theme.Marker)
|
theme.Marker = o(baseTheme.Marker, theme.Marker)
|
||||||
theme.Header = o(baseTheme.Header, theme.Header)
|
theme.Header = o(baseTheme.Header, theme.Header)
|
||||||
theme.Footer = o(baseTheme.Footer, theme.Footer)
|
theme.Footer = o(baseTheme.Footer, theme.Footer)
|
||||||
@@ -1230,10 +1340,8 @@ func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlac
|
|||||||
scrollbarDefined := theme.Scrollbar != undefined
|
scrollbarDefined := theme.Scrollbar != undefined
|
||||||
previewBorderDefined := theme.PreviewBorder != undefined
|
previewBorderDefined := theme.PreviewBorder != undefined
|
||||||
|
|
||||||
// These colors are not defined in the base themes
|
theme.NthSelectedAttr = theme.SelectedFg.Attr
|
||||||
theme.ListFg = o(theme.Fg, theme.ListFg)
|
theme.SelectedFg = theme.ListFg.Merge(theme.SelectedFg)
|
||||||
theme.ListBg = o(theme.Bg, theme.ListBg)
|
|
||||||
theme.SelectedFg = o(theme.ListFg, theme.SelectedFg)
|
|
||||||
theme.SelectedBg = o(theme.ListBg, theme.SelectedBg)
|
theme.SelectedBg = o(theme.ListBg, theme.SelectedBg)
|
||||||
theme.SelectedMatch = o(theme.Match, theme.SelectedMatch)
|
theme.SelectedMatch = o(theme.Match, theme.SelectedMatch)
|
||||||
|
|
||||||
@@ -1288,11 +1396,22 @@ func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlac
|
|||||||
} else {
|
} else {
|
||||||
theme.HeaderBg = o(theme.Bg, theme.ListBg)
|
theme.HeaderBg = o(theme.Bg, theme.ListBg)
|
||||||
}
|
}
|
||||||
theme.HeaderBorder = o(theme.Border, theme.HeaderBorder)
|
// Inline header/footer borders sit inside the list frame, so default their color
|
||||||
|
// to the list-border color when the user has not explicitly set it. The inline
|
||||||
|
// separator then matches the surrounding frame.
|
||||||
|
headerBorderFallback := theme.Border
|
||||||
|
if headerInline {
|
||||||
|
headerBorderFallback = theme.ListBorder
|
||||||
|
}
|
||||||
|
theme.HeaderBorder = o(headerBorderFallback, theme.HeaderBorder)
|
||||||
theme.HeaderLabel = o(theme.BorderLabel, theme.HeaderLabel)
|
theme.HeaderLabel = o(theme.BorderLabel, theme.HeaderLabel)
|
||||||
|
|
||||||
theme.FooterBg = o(theme.Bg, theme.FooterBg)
|
theme.FooterBg = o(theme.Bg, theme.FooterBg)
|
||||||
theme.FooterBorder = o(theme.Border, theme.FooterBorder)
|
footerBorderFallback := theme.Border
|
||||||
|
if footerInline {
|
||||||
|
footerBorderFallback = theme.ListBorder
|
||||||
|
}
|
||||||
|
theme.FooterBorder = o(footerBorderFallback, theme.FooterBorder)
|
||||||
theme.FooterLabel = o(theme.BorderLabel, theme.FooterLabel)
|
theme.FooterLabel = o(theme.BorderLabel, theme.FooterLabel)
|
||||||
|
|
||||||
if theme.Nomatch.IsUndefined() {
|
if theme.Nomatch.IsUndefined() {
|
||||||
@@ -1320,11 +1439,11 @@ func initPalette(theme *ColorTheme) {
|
|||||||
ColDisabled = pair(theme.Disabled, theme.InputBg)
|
ColDisabled = pair(theme.Disabled, theme.InputBg)
|
||||||
ColMatch = pair(theme.Match, theme.ListBg)
|
ColMatch = pair(theme.Match, theme.ListBg)
|
||||||
ColSelectedMatch = pair(theme.SelectedMatch, theme.SelectedBg)
|
ColSelectedMatch = pair(theme.SelectedMatch, theme.SelectedBg)
|
||||||
ColCursor = pair(theme.Cursor, theme.Gutter)
|
ColPointer = pair(theme.Pointer, theme.Gutter)
|
||||||
ColCursorEmpty = pair(blank, theme.Gutter)
|
ColPointerEmpty = pair(blank, theme.Gutter)
|
||||||
ColCursorEmptyChar = pair(theme.Gutter, theme.ListBg)
|
ColPointerEmptyChar = pair(theme.Gutter, theme.ListBg)
|
||||||
ColAltCursorEmpty = pair(blank, theme.AltGutter)
|
ColAltPointerEmpty = pair(blank, theme.AltGutter)
|
||||||
ColAltCursorEmptyChar = pair(theme.AltGutter, theme.ListBg)
|
ColAltPointerEmptyChar = pair(theme.AltGutter, theme.ListBg)
|
||||||
if theme.SelectedBg.Color != theme.ListBg.Color {
|
if theme.SelectedBg.Color != theme.ListBg.Color {
|
||||||
ColMarker = pair(theme.Marker, theme.SelectedBg)
|
ColMarker = pair(theme.Marker, theme.SelectedBg)
|
||||||
} else {
|
} else {
|
||||||
@@ -1332,8 +1451,8 @@ func initPalette(theme *ColorTheme) {
|
|||||||
}
|
}
|
||||||
ColCurrent = pair(theme.Current, theme.DarkBg)
|
ColCurrent = pair(theme.Current, theme.DarkBg)
|
||||||
ColCurrentMatch = pair(theme.CurrentMatch, theme.DarkBg)
|
ColCurrentMatch = pair(theme.CurrentMatch, theme.DarkBg)
|
||||||
ColCurrentCursor = pair(theme.Cursor, theme.DarkBg)
|
ColCurrentPointer = pair(theme.Pointer, theme.DarkBg)
|
||||||
ColCurrentCursorEmpty = pair(blank, theme.DarkBg)
|
ColCurrentPointerEmpty = pair(blank, theme.DarkBg)
|
||||||
ColCurrentMarker = pair(theme.Marker, theme.DarkBg)
|
ColCurrentMarker = pair(theme.Marker, theme.DarkBg)
|
||||||
ColCurrentSelectedEmpty = pair(blank, theme.DarkBg)
|
ColCurrentSelectedEmpty = pair(blank, theme.DarkBg)
|
||||||
ColSpinner = pair(theme.Spinner, theme.InputBg)
|
ColSpinner = pair(theme.Spinner, theme.InputBg)
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@ func TestWrapLine(t *testing.T) {
|
|||||||
t.Errorf("Basic wrap: %v", lines)
|
t.Errorf("Basic wrap: %v", lines)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exact fit — no wrapping needed
|
// Exact fit - no wrapping needed
|
||||||
lines = WrapLine("hello", 0, 5, 8, 2)
|
lines = WrapLine("hello", 0, 5, 8, 2)
|
||||||
if len(lines) != 1 || lines[0].Text != "hello" || lines[0].DisplayWidth != 5 {
|
if len(lines) != 1 || lines[0].Text != "hello" || lines[0].DisplayWidth != 5 {
|
||||||
t.Errorf("Exact fit: %v", lines)
|
t.Errorf("Exact fit: %v", lines)
|
||||||
|
|||||||
+141
-8
@@ -3,6 +3,7 @@ package util
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/bits"
|
||||||
"unicode"
|
"unicode"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
@@ -13,9 +14,17 @@ const (
|
|||||||
overflow32 uint32 = 0x80808080
|
overflow32 uint32 = 0x80808080
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
flagInBytes uint8 = 1 << iota
|
||||||
|
flagMayFold
|
||||||
|
)
|
||||||
|
|
||||||
type Chars struct {
|
type Chars struct {
|
||||||
slice []byte // or []rune
|
slice []byte // or []rune
|
||||||
inBytes bool
|
// Only ever set, never cleared, so a reader racing a Prepend sees either
|
||||||
|
// the old or the new value and both are safe. trimLength* is kept out
|
||||||
|
// because TrimLength rewrites it.
|
||||||
|
flags uint8
|
||||||
trimLengthKnown bool
|
trimLengthKnown bool
|
||||||
trimLength uint16
|
trimLength uint16
|
||||||
|
|
||||||
@@ -24,6 +33,59 @@ type Chars struct {
|
|||||||
Index int32
|
Index int32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rune ranges that case folding or normalization can turn into ASCII, derived
|
||||||
|
// from algo's normalization table and unicode.ToLower, then merged. They are a
|
||||||
|
// superset of the exact set, which TestMayFoldToAsciiIsSuperset in the algo
|
||||||
|
// package verifies. Grouped tightly on purpose: a wider merge would include
|
||||||
|
// Greek Extended, General Punctuation and the currency and letterlike blocks,
|
||||||
|
// and every line holding a curly quote or an em dash would then lose the
|
||||||
|
// prefilter. Cyrillic, Greek, Hebrew, Arabic, Thai, Devanagari, CJK, Hangul,
|
||||||
|
// kana, emoji, punctuation and box drawing are all outside.
|
||||||
|
const (
|
||||||
|
foldLo = 0x00C0
|
||||||
|
foldHi = 0xFF61
|
||||||
|
)
|
||||||
|
|
||||||
|
var foldableRanges = [...][2]rune{
|
||||||
|
{0x00C0, 0x01B6}, // Latin-1 Supplement, Latin Extended-A and -B
|
||||||
|
{0x01CD, 0x02AE}, // rest of Latin Extended-B and IPA Extensions
|
||||||
|
{0x0363, 0x036F}, // combining Latin small letters
|
||||||
|
{0x1D00, 0x1D22}, // Phonetic Extensions, small capitals
|
||||||
|
{0x1D62, 0x1D65}, // subscript letters
|
||||||
|
{0x1E00, 0x1EF9}, // Latin Extended Additional
|
||||||
|
{0x2071, 0x2071}, // superscript i
|
||||||
|
{0x2095, 0x209C}, // subscript letters
|
||||||
|
{0x212A, 0x212B}, // KELVIN SIGN and ANGSTROM SIGN, which fold by case
|
||||||
|
{0x2183, 0x2184}, // reversed roman numeral one hundred
|
||||||
|
{0x2C62, 0x2C7F}, // Latin Extended-C
|
||||||
|
{0xA78D, 0xA78D}, // Latin Extended-D
|
||||||
|
{0xA7AA, 0xA7B2}, // more Latin Extended-D
|
||||||
|
{0xA7C5, 0xA7C5},
|
||||||
|
{0xFF01, 0xFF61}, // fullwidth ASCII forms, and halfwidth ideographic full stop
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walking the ranges costs a serial chain of comparisons per rune, which is
|
||||||
|
// measurable at ingestion, so precompute a bitmap instead.
|
||||||
|
var foldableBits = func() (bits [(foldHi-foldLo)/8 + 1]byte) {
|
||||||
|
for _, r := range foldableRanges {
|
||||||
|
for c := r[0]; c <= r[1]; c++ {
|
||||||
|
i := c - foldLo
|
||||||
|
bits[i>>3] |= 1 << (i & 7)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}()
|
||||||
|
|
||||||
|
// MayFoldToAscii reports whether case folding or normalization could turn r
|
||||||
|
// into an ASCII character.
|
||||||
|
func MayFoldToAscii(r rune) bool {
|
||||||
|
i := uint32(r - foldLo)
|
||||||
|
if i > foldHi-foldLo {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return foldableBits[i>>3]&(1<<(i&7)) != 0
|
||||||
|
}
|
||||||
|
|
||||||
func checkAscii(bytes []byte) (bool, int) {
|
func checkAscii(bytes []byte) (bool, int) {
|
||||||
i := 0
|
i := 0
|
||||||
for ; i <= len(bytes)-8; i += 8 {
|
for ; i <= len(bytes)-8; i += 8 {
|
||||||
@@ -44,31 +106,94 @@ func checkAscii(bytes []byte) (bool, int) {
|
|||||||
return true, 0
|
return true, 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// countRunes counts the bytes that are not UTF-8 continuation bytes, which is
|
||||||
|
// the rune count of valid UTF-8. Each invalid byte decodes to its own
|
||||||
|
// RuneError, so the result can undercount but never overcount, making it safe
|
||||||
|
// as a capacity hint.
|
||||||
|
func countRunes(bytes []byte) int {
|
||||||
|
n, i := 0, 0
|
||||||
|
for ; i <= len(bytes)-8; i += 8 {
|
||||||
|
v := *(*uint64)(unsafe.Pointer(&bytes[i]))
|
||||||
|
// Continuation byte: bit 7 set, bit 6 clear. In `v << 1` bit 7 of each
|
||||||
|
// lane holds bit 6 of that same lane.
|
||||||
|
n += 8 - bits.OnesCount64(v&^(v<<1)&overflow64)
|
||||||
|
}
|
||||||
|
for ; i < len(bytes); i++ {
|
||||||
|
if bytes[i]&0xC0 != 0x80 {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
// ToChars converts byte array into rune array
|
// ToChars converts byte array into rune array
|
||||||
func ToChars(bytes []byte) Chars {
|
func ToChars(bytes []byte) Chars {
|
||||||
inBytes, bytesUntil := checkAscii(bytes)
|
inBytes, bytesUntil := checkAscii(bytes)
|
||||||
if inBytes {
|
if inBytes {
|
||||||
return Chars{slice: bytes, inBytes: inBytes}
|
return Chars{slice: bytes, flags: flagInBytes}
|
||||||
}
|
}
|
||||||
|
|
||||||
runes := make([]rune, bytesUntil, len(bytes))
|
runes := make([]rune, bytesUntil, bytesUntil+countRunes(bytes[bytesUntil:]))
|
||||||
for i := range bytesUntil {
|
for i := range bytesUntil {
|
||||||
runes[i] = rune(bytes[i])
|
runes[i] = rune(bytes[i])
|
||||||
}
|
}
|
||||||
|
mayFold := false
|
||||||
for i := bytesUntil; i < len(bytes); {
|
for i := bytesUntil; i < len(bytes); {
|
||||||
|
// utf8.DecodeRune has an ASCII path of its own, but it is too complex
|
||||||
|
// to inline, so a mostly-ASCII line pays one call per byte for it.
|
||||||
|
// An ASCII rune never sets the fold bit either, so skip both calls.
|
||||||
|
if b := bytes[i]; b < utf8.RuneSelf {
|
||||||
|
runes = append(runes, rune(b))
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
r, sz := utf8.DecodeRune(bytes[i:])
|
r, sz := utf8.DecodeRune(bytes[i:])
|
||||||
i += sz
|
i += sz
|
||||||
|
mayFold = mayFold || MayFoldToAscii(r)
|
||||||
runes = append(runes, r)
|
runes = append(runes, r)
|
||||||
}
|
}
|
||||||
return RunesToChars(runes)
|
return runesToChars(runes, mayFold)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RunesToChars adopts the caller's slice rather than copying it, so the caller
|
||||||
|
// must not keep mutating it. See Runes for why.
|
||||||
func RunesToChars(runes []rune) Chars {
|
func RunesToChars(runes []rune) Chars {
|
||||||
return Chars{slice: *(*[]byte)(unsafe.Pointer(&runes)), inBytes: false}
|
mayFold := false
|
||||||
|
for _, r := range runes {
|
||||||
|
if MayFoldToAscii(r) {
|
||||||
|
mayFold = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return runesToChars(runes, mayFold)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runesToChars(runes []rune, mayFold bool) Chars {
|
||||||
|
var flags uint8
|
||||||
|
if mayFold {
|
||||||
|
flags = flagMayFold
|
||||||
|
}
|
||||||
|
return Chars{slice: *(*[]byte)(unsafe.Pointer(&runes)), flags: flags}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (chars *Chars) IsBytes() bool {
|
func (chars *Chars) IsBytes() bool {
|
||||||
return chars.inBytes
|
return chars.flags&flagInBytes != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// MayFoldToAscii reports whether the text holds a rune that case folding or
|
||||||
|
// normalization could turn into an ASCII character. When false, an ASCII
|
||||||
|
// pattern character can only match the identical ASCII rune, which is what
|
||||||
|
// lets the prefilter scan the rune array directly.
|
||||||
|
func (chars *Chars) MayFoldToAscii() bool {
|
||||||
|
return chars.flags&flagMayFold != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runes returns the underlying rune slice, or nil if the text is kept as
|
||||||
|
// bytes. Read only. The result aliases the text, so writing to it would change
|
||||||
|
// the text without updating the cached fold bit, and the prefilter would then
|
||||||
|
// reject items it should match. Copy before mutating.
|
||||||
|
func (chars *Chars) Runes() []rune {
|
||||||
|
return chars.optionalRunes()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (chars *Chars) Bytes() []byte {
|
func (chars *Chars) Bytes() []byte {
|
||||||
@@ -105,7 +230,7 @@ func (chars *Chars) NumLines(atMost int) (int, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (chars *Chars) optionalRunes() []rune {
|
func (chars *Chars) optionalRunes() []rune {
|
||||||
if chars.inBytes {
|
if chars.IsBytes() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return *(*[]rune)(unsafe.Pointer(&chars.slice))
|
return *(*[]rune)(unsafe.Pointer(&chars.slice))
|
||||||
@@ -127,7 +252,7 @@ func (chars *Chars) Length() int {
|
|||||||
|
|
||||||
// String returns the string representation of a Chars object.
|
// String returns the string representation of a Chars object.
|
||||||
func (chars *Chars) String() string {
|
func (chars *Chars) String() string {
|
||||||
return fmt.Sprintf("Chars{slice: []byte(%q), inBytes: %v, trimLengthKnown: %v, trimLength: %d, Index: %d}", chars.slice, chars.inBytes, chars.trimLengthKnown, chars.trimLength, chars.Index)
|
return fmt.Sprintf("Chars{slice: []byte(%q), inBytes: %v, mayFold: %v, trimLengthKnown: %v, trimLength: %d, Index: %d}", chars.slice, chars.IsBytes(), chars.MayFoldToAscii(), chars.trimLengthKnown, chars.trimLength, chars.Index)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TrimLength returns the length after trimming leading and trailing whitespaces
|
// TrimLength returns the length after trimming leading and trailing whitespaces
|
||||||
@@ -218,6 +343,8 @@ func (chars *Chars) ToString() string {
|
|||||||
return unsafe.String(unsafe.SliceData(chars.slice), len(chars.slice))
|
return unsafe.String(unsafe.SliceData(chars.slice), len(chars.slice))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ToRunes returns the text as runes. In rune mode the result aliases the text
|
||||||
|
// and must not be mutated, see Runes. In byte mode it is a fresh slice.
|
||||||
func (chars *Chars) ToRunes() []rune {
|
func (chars *Chars) ToRunes() []rune {
|
||||||
if runes := chars.optionalRunes(); runes != nil {
|
if runes := chars.optionalRunes(); runes != nil {
|
||||||
return runes
|
return runes
|
||||||
@@ -247,6 +374,12 @@ func (chars *Chars) Prepend(prefix string) {
|
|||||||
} else {
|
} else {
|
||||||
chars.slice = append([]byte(prefix), chars.slice...)
|
chars.slice = append([]byte(prefix), chars.slice...)
|
||||||
}
|
}
|
||||||
|
for _, r := range prefix {
|
||||||
|
if MayFoldToAscii(r) {
|
||||||
|
chars.flags |= flagMayFold
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (chars *Chars) Lines(multiLine bool, maxLines int, wrapCols int, wrapSignWidth int, tabstop int, wrapWord bool) ([][]rune, bool) {
|
func (chars *Chars) Lines(multiLine bool, maxLines int, wrapCols int, wrapSignWidth int, tabstop int, wrapWord bool) ([][]rune, bool) {
|
||||||
|
|||||||
+162
-20
@@ -2,19 +2,106 @@ package util
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
|
"unsafe"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestCountRunes(t *testing.T) {
|
||||||
|
for _, str := range []string{
|
||||||
|
"", "a", "abc", "한글", "🎉🎉", "\tabc한글 ",
|
||||||
|
strings.Repeat("漢字", 50), strings.Repeat("a", 33) + "é",
|
||||||
|
} {
|
||||||
|
if got, exp := countRunes([]byte(str)), utf8.RuneCountInString(str); got != exp {
|
||||||
|
t.Errorf("countRunes(%q) = %d, expected %d", str, got, exp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCountRunesRandom(t *testing.T) {
|
||||||
|
rng := rand.New(rand.NewSource(1))
|
||||||
|
|
||||||
|
// Exact on valid UTF-8
|
||||||
|
for trial := range 20000 {
|
||||||
|
var sb strings.Builder
|
||||||
|
for range rng.Intn(20) {
|
||||||
|
r := rune(rng.Intn(utf8.MaxRune + 1))
|
||||||
|
for r >= 0xD800 && r <= 0xDFFF {
|
||||||
|
r = rune(rng.Intn(utf8.MaxRune + 1))
|
||||||
|
}
|
||||||
|
sb.WriteRune(r)
|
||||||
|
}
|
||||||
|
str := sb.String()
|
||||||
|
if got, exp := countRunes([]byte(str)), utf8.RuneCountInString(str); got != exp {
|
||||||
|
t.Fatalf("trial %d: countRunes(%q) = %d, expected %d", trial, str, got, exp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never an overcount on arbitrary bytes, so the capacity hint never truncates
|
||||||
|
for trial := range 20000 {
|
||||||
|
buf := make([]byte, rng.Intn(40))
|
||||||
|
rng.Read(buf)
|
||||||
|
if got, exp := countRunes(buf), utf8.RuneCount(buf); got > exp {
|
||||||
|
t.Fatalf("trial %d: countRunes(%x) = %d, overcounts %d", trial, buf, got, exp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToChars must produce exactly what a []rune conversion produces, including
|
||||||
|
// one RuneError per invalid byte, and must size the rune slice exactly when
|
||||||
|
// the input is valid UTF-8.
|
||||||
|
func TestToCharsIntegrity(t *testing.T) {
|
||||||
|
rng := rand.New(rand.NewSource(2))
|
||||||
|
check := func(buf []byte, exactCap bool) {
|
||||||
|
chars := ToChars(buf)
|
||||||
|
exp := []rune(string(buf))
|
||||||
|
if chars.Length() != len(exp) {
|
||||||
|
t.Fatalf("ToChars(%x).Length() = %d, expected %d", buf, chars.Length(), len(exp))
|
||||||
|
}
|
||||||
|
for i, r := range exp {
|
||||||
|
if chars.Get(i) != r {
|
||||||
|
t.Fatalf("ToChars(%x).Get(%d) = %q, expected %q", buf, i, chars.Get(i), r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if runes := chars.optionalRunes(); runes != nil && exactCap && cap(runes) != len(exp) {
|
||||||
|
t.Fatalf("ToChars(%x) cap = %d, expected %d", buf, cap(runes), len(exp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for range 5000 {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("ascii")
|
||||||
|
for range 1 + rng.Intn(10) {
|
||||||
|
r := rune(0x80 + rng.Intn(utf8.MaxRune-0x80))
|
||||||
|
for r >= 0xD800 && r <= 0xDFFF {
|
||||||
|
r = rune(0x80 + rng.Intn(utf8.MaxRune-0x80))
|
||||||
|
}
|
||||||
|
sb.WriteRune(r)
|
||||||
|
}
|
||||||
|
check([]byte(sb.String()), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalid UTF-8: still correct, capacity may grow
|
||||||
|
for range 5000 {
|
||||||
|
buf := make([]byte, 1+rng.Intn(40))
|
||||||
|
rng.Read(buf)
|
||||||
|
buf[rng.Intn(len(buf))] |= 0x80 // force the rune path
|
||||||
|
check(buf, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestToCharsAscii(t *testing.T) {
|
func TestToCharsAscii(t *testing.T) {
|
||||||
chars := ToChars([]byte("foobar"))
|
chars := ToChars([]byte("foobar"))
|
||||||
if !chars.inBytes || chars.ToString() != "foobar" || !chars.inBytes {
|
if !chars.IsBytes() || chars.ToString() != "foobar" {
|
||||||
t.Error()
|
t.Error()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCharsLength(t *testing.T) {
|
func TestCharsLength(t *testing.T) {
|
||||||
chars := ToChars([]byte("\tabc한글 "))
|
chars := ToChars([]byte("\tabc한글 "))
|
||||||
if chars.inBytes || chars.Length() != 8 || chars.TrimLength() != 5 {
|
if chars.IsBytes() || chars.Length() != 8 || chars.TrimLength() != 5 {
|
||||||
t.Error()
|
t.Error()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,25 +194,80 @@ func TestCharsLinesWrapWord(t *testing.T) {
|
|||||||
t.Errorf("Expected first line 'abcdefghij', got %q", string(lines2[0]))
|
t.Errorf("Expected first line 'abcdefghij', got %q", string(lines2[0]))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tab as word boundary
|
|
||||||
chars3 := ToChars([]byte("hello\tworld"))
|
|
||||||
lines3, _ := chars3.Lines(false, 100, 7, 0, 8, true)
|
|
||||||
// "hello\t" should break at tab (width of tab at pos 5 with tabstop 8 = 3, total width = 8 > 7)
|
|
||||||
// Actually RunesWidth: 'h'=1,'e'=1,'l'=1,'l'=1,'o'=1,'\t'=3 = 8 > 7, overflowIdx=5
|
|
||||||
// Then word-wrap scans back and finds no space/tab before idx 5 (tab IS at idx 5 but we check line[k-1])
|
|
||||||
// Wait - let me think: overflowIdx=5, we check k=5 -> line[4]='o', k=4 -> line[3]='l'... no space/tab found
|
|
||||||
// Falls back to character wrap: "hello" | "\tworld"
|
|
||||||
if len(lines3) < 2 {
|
|
||||||
t.Errorf("Expected at least 2 lines for tab test, got %d: %v", len(lines3), lines3)
|
|
||||||
}
|
|
||||||
|
|
||||||
// wrapWord=false still character-wraps
|
// wrapWord=false still character-wraps
|
||||||
chars4 := ToChars([]byte("hello world"))
|
chars3 := ToChars([]byte("hello world"))
|
||||||
lines4, _ := chars4.Lines(false, 100, 8, 0, 8, false)
|
lines3, _ := chars3.Lines(false, 100, 8, 0, 8, false)
|
||||||
if len(lines4) != 2 {
|
if len(lines3) != 2 {
|
||||||
t.Errorf("Expected 2 lines with wrapWord=false, got %d: %v", len(lines4), lines4)
|
t.Errorf("Expected 2 lines with wrapWord=false, got %d: %v", len(lines3), lines3)
|
||||||
}
|
}
|
||||||
if string(lines4[0]) != "hello wo" {
|
if string(lines3[0]) != "hello wo" {
|
||||||
t.Errorf("Expected first line 'hello wo', got %q", string(lines4[0]))
|
t.Errorf("Expected first line 'hello wo', got %q", string(lines3[0]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chars is one per input line, so its size matters. It has no spare padding,
|
||||||
|
// which is why new state goes in the flags byte rather than a field.
|
||||||
|
// Derive the expectation from the slice header so the invariant holds on
|
||||||
|
// 32-bit builds too, where the header is 12 bytes and Chars is 20.
|
||||||
|
func TestCharsSize(t *testing.T) {
|
||||||
|
var slice []byte
|
||||||
|
// flags 1 + trimLengthKnown 1 + trimLength 2 + Index 4, no padding
|
||||||
|
want := unsafe.Sizeof(slice) + 8
|
||||||
|
if size := unsafe.Sizeof(Chars{}); size != want {
|
||||||
|
t.Errorf("unsafe.Sizeof(Chars{}) = %d, expected %d", size, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMayFoldFlag(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
text string
|
||||||
|
fold bool
|
||||||
|
}{
|
||||||
|
{"한글/src", false}, {"漢字", false}, {"мир", false}, {"🎉", false},
|
||||||
|
{"café", true}, {"Müller", true}, {"Å", true}, {"full", true},
|
||||||
|
} {
|
||||||
|
chars := ToChars([]byte(c.text))
|
||||||
|
if chars.MayFoldToAscii() != c.fold {
|
||||||
|
t.Errorf("ToChars(%q).MayFoldToAscii() = %v, expected %v", c.text, chars.MayFoldToAscii(), c.fold)
|
||||||
|
}
|
||||||
|
if runes := RunesToChars([]rune(c.text)); runes.MayFoldToAscii() != c.fold {
|
||||||
|
t.Errorf("RunesToChars(%q).MayFoldToAscii() = %v, expected %v", c.text, runes.MayFoldToAscii(), c.fold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepend can introduce foldable runes
|
||||||
|
chars := ToChars([]byte("한글"))
|
||||||
|
if chars.MayFoldToAscii() {
|
||||||
|
t.Fatal("baseline should not be foldable")
|
||||||
|
}
|
||||||
|
chars.Prepend("é")
|
||||||
|
if !chars.MayFoldToAscii() {
|
||||||
|
t.Error("Prepend of a foldable prefix must set the flag")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runes and ToRunes alias the text in rune mode, so a consumer that mutates
|
||||||
|
// what they return changes the text without updating the cached fold bit. This
|
||||||
|
// verifies the aliasing so the read-only contract on those methods is not
|
||||||
|
// silently dropped later.
|
||||||
|
func TestRuneSlicesAliasTheText(t *testing.T) {
|
||||||
|
chars := ToChars([]byte("한글abc"))
|
||||||
|
runes := chars.Runes()
|
||||||
|
if runes == nil {
|
||||||
|
t.Fatal("expected rune mode")
|
||||||
|
}
|
||||||
|
if &runes[0] != &chars.ToRunes()[0] {
|
||||||
|
t.Error("Runes and ToRunes should return the same backing array")
|
||||||
|
}
|
||||||
|
if chars.MayFoldToAscii() {
|
||||||
|
t.Fatal("baseline should not be foldable")
|
||||||
|
}
|
||||||
|
// Demonstrates why callers must copy: the flag does not follow the text.
|
||||||
|
runes[0] = 'e'
|
||||||
|
if chars.MayFoldToAscii() {
|
||||||
|
t.Error("flag unexpectedly updated")
|
||||||
|
}
|
||||||
|
if got := chars.ToString(); got != "e글abc" {
|
||||||
|
t.Errorf("expected the write to reach the text, got %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/junegunn/go-shellwords"
|
||||||
"golang.org/x/sys/unix"
|
"golang.org/x/sys/unix"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,8 +21,8 @@ type Executor struct {
|
|||||||
|
|
||||||
func NewExecutor(withShell string) *Executor {
|
func NewExecutor(withShell string) *Executor {
|
||||||
shell := os.Getenv("SHELL")
|
shell := os.Getenv("SHELL")
|
||||||
args := strings.Fields(withShell)
|
args, err := shellwords.Parse(withShell)
|
||||||
if len(args) > 0 {
|
if err == nil && len(args) > 0 {
|
||||||
shell = args[0]
|
shell = args[0]
|
||||||
args = args[1:]
|
args = args[1:]
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
)
|
)
|
||||||
|
|
||||||
type shellType int
|
type shellType int
|
||||||
@@ -19,6 +21,7 @@ const (
|
|||||||
shellTypeUnknown shellType = iota
|
shellTypeUnknown shellType = iota
|
||||||
shellTypeCmd
|
shellTypeCmd
|
||||||
shellTypePowerShell
|
shellTypePowerShell
|
||||||
|
shellTypePwsh
|
||||||
)
|
)
|
||||||
|
|
||||||
var escapeRegex = regexp.MustCompile(`[&|<>()^%!"]`)
|
var escapeRegex = regexp.MustCompile(`[&|<>()^%!"]`)
|
||||||
@@ -46,7 +49,10 @@ func NewExecutor(withShell string) *Executor {
|
|||||||
} else if strings.HasPrefix(basename, "cmd") {
|
} else if strings.HasPrefix(basename, "cmd") {
|
||||||
shellType = shellTypeCmd
|
shellType = shellTypeCmd
|
||||||
args = []string{"/s/c"}
|
args = []string{"/s/c"}
|
||||||
} else if strings.HasPrefix(basename, "pwsh") || strings.HasPrefix(basename, "powershell") {
|
} else if strings.HasPrefix(basename, "pwsh") {
|
||||||
|
shellType = shellTypePwsh
|
||||||
|
args = []string{"-NoProfile", "-Command"}
|
||||||
|
} else if strings.HasPrefix(basename, "powershell") {
|
||||||
shellType = shellTypePowerShell
|
shellType = shellTypePowerShell
|
||||||
args = []string{"-NoProfile", "-Command"}
|
args = []string{"-NoProfile", "-Command"}
|
||||||
} else {
|
} else {
|
||||||
@@ -56,8 +62,12 @@ func NewExecutor(withShell string) *Executor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ExecCommand executes the given command with $SHELL
|
// ExecCommand executes the given command with $SHELL
|
||||||
// FIXME: setpgid is unused. We set it in the Unix implementation so that we
|
//
|
||||||
// can kill preview process with its child processes at once.
|
// On Windows, setpgid controls whether the spawned process is placed in a new
|
||||||
|
// process group (so that it can be signaled independently, e.g. for previews).
|
||||||
|
// However, we only do this for "pwsh" and non-standard shells, because cmd.exe
|
||||||
|
// and Windows PowerShell ("powershell.exe") don't always exit on Ctrl-Break.
|
||||||
|
//
|
||||||
// NOTE: For "powershell", we should ideally set output encoding to UTF8,
|
// NOTE: For "powershell", we should ideally set output encoding to UTF8,
|
||||||
// but it is left as is now because no adverse effect has been observed.
|
// but it is left as is now because no adverse effect has been observed.
|
||||||
func (x *Executor) ExecCommand(command string, setpgid bool) *exec.Cmd {
|
func (x *Executor) ExecCommand(command string, setpgid bool) *exec.Cmd {
|
||||||
@@ -73,19 +83,31 @@ func (x *Executor) ExecCommand(command string, setpgid bool) *exec.Cmd {
|
|||||||
}
|
}
|
||||||
x.shellPath.Store(shell)
|
x.shellPath.Store(shell)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var creationFlags uint32
|
||||||
|
// Set new process group for pwsh (PowerShell 7+) and unknown/posix-ish shells
|
||||||
|
if setpgid && (x.shellType == shellTypePwsh || x.shellType == shellTypeUnknown) {
|
||||||
|
creationFlags = windows.CREATE_NEW_PROCESS_GROUP
|
||||||
|
}
|
||||||
|
|
||||||
var cmd *exec.Cmd
|
var cmd *exec.Cmd
|
||||||
if x.shellType == shellTypeCmd {
|
if x.shellType == shellTypeCmd {
|
||||||
cmd = exec.Command(shell)
|
cmd = exec.Command(shell)
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||||
HideWindow: false,
|
HideWindow: false,
|
||||||
CmdLine: fmt.Sprintf(`%s "%s"`, strings.Join(x.args, " "), command),
|
CmdLine: fmt.Sprintf(`%s "%s"`, strings.Join(x.args, " "), command),
|
||||||
CreationFlags: 0,
|
CreationFlags: creationFlags,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
cmd = exec.Command(shell, append(x.args, command)...)
|
args := x.args
|
||||||
|
if setpgid && x.shellType == shellTypePwsh {
|
||||||
|
// pwsh needs -NonInteractive flag to exit on Ctrl-Break
|
||||||
|
args = append([]string{"-NonInteractive"}, x.args...)
|
||||||
|
}
|
||||||
|
cmd = exec.Command(shell, append(args, command)...)
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||||
HideWindow: false,
|
HideWindow: false,
|
||||||
CreationFlags: 0,
|
CreationFlags: creationFlags,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return cmd
|
return cmd
|
||||||
@@ -156,7 +178,7 @@ func (x *Executor) QuoteEntry(entry string) string {
|
|||||||
fd -H --no-ignore -td -d 4 | fzf --preview ".\eza.exe --color=always --tree --level=3 --icons=always {}" --with-shell "powershell -NoProfile -Command"
|
fd -H --no-ignore -td -d 4 | fzf --preview ".\eza.exe --color=always --tree --level=3 --icons=always {}" --with-shell "powershell -NoProfile -Command"
|
||||||
*/
|
*/
|
||||||
return escapeArg(entry)
|
return escapeArg(entry)
|
||||||
case shellTypePowerShell:
|
case shellTypePowerShell, shellTypePwsh:
|
||||||
escaped := strings.ReplaceAll(entry, `"`, `\"`)
|
escaped := strings.ReplaceAll(entry, `"`, `\"`)
|
||||||
return "'" + strings.ReplaceAll(escaped, "'", "''") + "'"
|
return "'" + strings.ReplaceAll(escaped, "'", "''") + "'"
|
||||||
default:
|
default:
|
||||||
@@ -166,6 +188,21 @@ func (x *Executor) QuoteEntry(entry string) string {
|
|||||||
|
|
||||||
// KillCommand kills the process for the given command
|
// KillCommand kills the process for the given command
|
||||||
func KillCommand(cmd *exec.Cmd) error {
|
func KillCommand(cmd *exec.Cmd) error {
|
||||||
|
// Safely handle nil command or process.
|
||||||
|
if cmd == nil || cmd.Process == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// If it has its own process group, we can send it Ctrl-Break
|
||||||
|
if cmd.SysProcAttr != nil && cmd.SysProcAttr.CreationFlags&windows.CREATE_NEW_PROCESS_GROUP != 0 {
|
||||||
|
if err := windows.GenerateConsoleCtrlEvent(windows.CTRL_BREAK_EVENT, uint32(cmd.Process.Pid)); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If it's the same process group, or if sending the console control event
|
||||||
|
// fails (e.g., no console, different console, or process already exited),
|
||||||
|
// fall back to a standard kill. This probably won't *help* if there's I/O
|
||||||
|
// going on, because Wait() will still hang until the I/O finishes unless we
|
||||||
|
// hard-kill the entire process group. But it doesn't hurt to try!
|
||||||
return cmd.Process.Kill()
|
return cmd.Process.Kill()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user