mirror of
https://github.com/junegunn/fzf
synced 2026-06-09 10:03:17 +00:00
Compare commits
209 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| b908f7a0ec | |||
| 1a50a3c082 | |||
| fefea8d885 | |||
| 385cccd362 | |||
| 4a684b6c78 | |||
| 4a195e6323 | |||
| 0ecbf3f475 | |||
| 4522868fc0 | |||
| 111a62f1ea | |||
| 33cac3f0e7 | |||
| 74e98cac5c | |||
| c338df02c4 | |||
| 69e9abdab4 | |||
| b6411beaa1 | |||
| b56d614ba2 | |||
| 49ab253555 | |||
| 0e859a18ed | |||
| 880dd20b18 | |||
| 91aa25c863 | |||
| 7e62b34087 | |||
| f9f0014c16 | |||
| ab3b9fef52 | |||
| 7d9724157c | |||
| bc8967632b | |||
| 6360c9261c | |||
| e653628458 | |||
| 28747e5cb6 | |||
| 9725eac314 | |||
| b389616030 | |||
| 25b2248f11 | |||
| 14564e4fc7 | |||
| d01eaa9de3 | |||
| aad805d0d3 | |||
| d1f037059a | |||
| 3f94bcb5bf | |||
| 3c7cbc9d47 | |||
| 28e2a067e5 | |||
| b29e2ee2d1 | |||
| 029b241dbb | |||
| d6ded42026 | |||
| 6eb4b41e34 | |||
| 14b5e1d88c | |||
| 603240122e | |||
| 8d688521fe | |||
| 775129367a | |||
| b3b221854b | |||
| c8cf0992c1 | |||
| 33d8d51c8a | |||
| b473477c22 | |||
| fcc4178bca | |||
| cfc37caabc | |||
| af2a81dc02 | |||
| be5a687281 | |||
| 771e35b972 | |||
| 60a5be1e65 |
+1
-1
@@ -1,6 +1,6 @@
|
||||
root = true
|
||||
|
||||
[*.{sh,bash}]
|
||||
[*.{sh,bash,fish}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
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
|
||||
@@ -11,4 +11,4 @@ jobs:
|
||||
- name: 'Checkout Repository'
|
||||
uses: actions/checkout@v5
|
||||
- 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@v6
|
||||
with:
|
||||
configuration-path: .github/labeler.yml
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
push:
|
||||
branches: [ master, devel ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
branches: [ master, devel ]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -28,12 +28,17 @@ jobs:
|
||||
go-version: "1.23"
|
||||
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
uses: ruby/setup-ruby@97ecb7b512899eb71ab1bf2310a624c6f1589ac6 # v1
|
||||
with:
|
||||
ruby-version: 3.4.6
|
||||
|
||||
- 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
|
||||
run: bundle install
|
||||
@@ -44,5 +49,10 @@ jobs:
|
||||
- name: Unit 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
|
||||
run: make install && ./install --all && tmux new-session -d && ruby test/runner.rb --verbose
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
go-version: "1.23"
|
||||
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
uses: ruby/setup-ruby@97ecb7b512899eb71ab1bf2310a624c6f1589ac6 # v1
|
||||
with:
|
||||
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@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
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 0 * * 0
|
||||
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: '.'
|
||||
@@ -7,4 +7,4 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: crate-ci/typos@v1.29.4
|
||||
- uses: crate-ci/typos@685eb3d55be2f85191e8c84acb9f44d7756f84ab # v1.29.4
|
||||
|
||||
@@ -2,13 +2,20 @@ name: Publish to Winget
|
||||
on:
|
||||
release:
|
||||
types: [released]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release-tag:
|
||||
description: 'Release tag to submit (e.g. v0.73.1)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: vedantmgoyal2009/winget-releaser@v2
|
||||
- uses: vedantmgoyal2009/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2
|
||||
with:
|
||||
identifier: junegunn.fzf
|
||||
release-tag: ${{ inputs.release-tag || github.event.release.tag_name }}
|
||||
installers-regex: '-windows_(armv7|arm64|amd64)\.zip$'
|
||||
token: ${{ secrets.WINGET_TOKEN }}
|
||||
|
||||
+6
-4
@@ -85,12 +85,14 @@ notarize:
|
||||
|
||||
archives:
|
||||
- name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
|
||||
builds:
|
||||
ids:
|
||||
- fzf
|
||||
format: tar.gz
|
||||
formats:
|
||||
- tar.gz
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
formats:
|
||||
- zip
|
||||
files:
|
||||
- non-existent*
|
||||
|
||||
@@ -102,7 +104,7 @@ release:
|
||||
name_template: '{{ .Version }}'
|
||||
|
||||
snapshot:
|
||||
name_template: "{{ .Version }}-devel"
|
||||
version_template: "{{ .Version }}-devel"
|
||||
|
||||
changelog:
|
||||
sort: asc
|
||||
|
||||
+8
-8
@@ -309,16 +309,16 @@ I know it's a lot to digest, let's try to break down the code.
|
||||
available color options.
|
||||
- The value of `--preview-window` option consists of 5 components delimited
|
||||
by `,`
|
||||
1. `up` — Position of the preview window
|
||||
1. `60%` — Size of the preview window
|
||||
1. `border-bottom` — Preview window border only on the bottom side
|
||||
1. `+{2}+3/3` — Scroll offset of the preview contents
|
||||
1. `~3` — Fixed header
|
||||
1. `up` -- Position of the preview window
|
||||
1. `60%` -- Size of the preview window
|
||||
1. `border-bottom` -- Preview window border only on the bottom side
|
||||
1. `+{2}+3/3` -- Scroll offset of the preview contents
|
||||
1. `~3` -- Fixed header
|
||||
- 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
|
||||
positioned near the center of the preview window.
|
||||
- `+{2}` — The base offset is extracted from the second token
|
||||
- `+3` — We add 3 lines to the base offset to compensate for the header
|
||||
- `+{2}` -- The base offset is extracted from the second token
|
||||
- `+3` -- We add 3 lines to the base offset to compensate for the header
|
||||
part of `bat` output
|
||||
- ```
|
||||
───────┬──────────────────────────────────────────────────────────
|
||||
@@ -363,7 +363,7 @@ projects, and it will free up memory as you narrow down the results.
|
||||
RG_PREFIX="rg --column --line-number --no-heading --color=always --smart-case "
|
||||
INITIAL_QUERY="${*:-}"
|
||||
fzf --ansi --disabled --query "$INITIAL_QUERY" \
|
||||
--bind "start:reload:$RG_PREFIX {q}" \
|
||||
--bind "start:reload:$RG_PREFIX {q} || true" \
|
||||
--bind "change:reload:sleep 0.1; $RG_PREFIX {q} || true" \
|
||||
--delimiter : \
|
||||
--preview 'bat --color=always {1} --highlight-line {2}' \
|
||||
|
||||
+202
@@ -1,6 +1,208 @@
|
||||
CHANGELOG
|
||||
=========
|
||||
|
||||
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
|
||||
------
|
||||
- Implemented word wrapping in the list section
|
||||
- Added `--wrap=word` (or `--wrap-word`) option and `toggle-wrap-word` action for word-level line wrapping in the list section
|
||||
- Changed default binding of `ctrl-/` and `alt-/` from `toggle-wrap` to `toggle-wrap-word`
|
||||
```sh
|
||||
fzf --wrap=word
|
||||
```
|
||||
- Implemented word wrapping in the preview window
|
||||
- Added `wrap-word` flag for `--preview-window` to enable word-level wrapping
|
||||
- Added `toggle-preview-wrap-word` action
|
||||
```sh
|
||||
fzf --preview 'bat --style=plain --color=always {}' \
|
||||
--preview-window wrap-word \
|
||||
--bind space:toggle-preview-wrap-word
|
||||
```
|
||||
- Added support for underline style variants in `--color`: `underline-double`, `underline-curly`, `underline-dotted`, `underline-dashed`
|
||||
```sh
|
||||
fzf --color 'fg:underline-curly,current-fg:underline-dashed'
|
||||
```
|
||||
- Added support for underline styles (`4:N`) and underline colors (SGR 58/59)
|
||||
```sh
|
||||
# In the list section
|
||||
printf '\e[4:3;58;2;255;0;0mRed curly underline\e[0m\n' | fzf --ansi
|
||||
|
||||
# In the preview window
|
||||
fzf --preview "printf '\e[4:3;58;2;255;0;0mRed curly underline\e[0m\n'"
|
||||
```
|
||||
- Added `--preview-wrap-sign` to set a different wrap indicator for the preview window
|
||||
- Added `alt-gutter` color option (#4602) (@hedgieinsocks)
|
||||
- Added `$FZF_WRAP` environment variable to child processes (`char` or `word` when wrapping is enabled) (#4672) (@bitraid)
|
||||
- fish: Improved command history (CTRL-R) (#4672) (@bitraid)
|
||||
- Enabled syntax highlighting in the list on fish 4.3.3+
|
||||
- Added syntax-highlighted preview window that auto-shows for long or multi-line commands
|
||||
- Added `ALT-ENTER` to reformat and insert selected commands
|
||||
- Improved handling of bulk deletion of selected history entries (`SHIFT-DELETE`)
|
||||
- Added fish completion support (#4605) (@lalvarezt)
|
||||
- zsh: Handle multi-line history selection (#4595) (@LangLangBart)
|
||||
- Bug fixes
|
||||
- Fixed `_fzf_compgen_{path,dir}` to respect `FZF_COMPLETION_{PATH,DIR}_OPTS` (#4592) (@shtse8, @LangLangBart)
|
||||
- Fixed `--preview-window follow` not working correctly with wrapping (#3243, #4258)
|
||||
- Fixed symlinks to directories being returned as files (#4676) (@skk64)
|
||||
- Fixed SIGHUP signal handling (#4668) (@LangLangBart)
|
||||
- Fixed preview process not killed on exit (#4667)
|
||||
- Fixed coloring of items with zero-width characters (#4620)
|
||||
- Fixed `track-current` unset after a combined movement action (#4649)
|
||||
- Fixed `--accept-nth` being ignored in filter mode (#4636) (@charemma)
|
||||
- Fixed display width calculation with `maxWidth` (#4596) (@LangLangBart)
|
||||
- Fixed clearing of the rest of the current line on start (#4652)
|
||||
- Fixed `x-api-key` header not required for GET requests (#4627)
|
||||
- Fixed key reading not cancelled when `execute` triggered via a server request (#4653)
|
||||
- Fixed rebind of readline command `redraw-current-line` (#4635) (@jameslazo)
|
||||
- Fixed `fzf-tmux` `TERM` quoting and added `mktemp` usage (#4664) (@Goofygiraffe06)
|
||||
- Do not allow very long queries in `FuzzyMatchV2` (#4608)
|
||||
|
||||
0.67.0
|
||||
------
|
||||
- Added `--freeze-left=N` option to keep the leftmost N columns always visible.
|
||||
|
||||
+11
-1
@@ -1,5 +1,15 @@
|
||||
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 echo '. /usr/share/bash-completion/completions/git' >> ~/.bashrc
|
||||
RUN echo '. ~/.bashrc' >> ~/.bash_profile
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2013-2025 Junegunn Choi
|
||||
Copyright (c) 2013-2026 Junegunn Choi
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
GO ?= go
|
||||
DOCKER ?= docker
|
||||
GOOS ?= $(shell $(GO) env GOOS)
|
||||
|
||||
MAKEFILE := $(realpath $(lastword $(MAKEFILE_LIST)))
|
||||
@@ -52,6 +53,8 @@ ifeq ($(UNAME_M),x86_64)
|
||||
BINARY := $(BINARY64)
|
||||
else ifeq ($(UNAME_M),amd64)
|
||||
BINARY := $(BINARY64)
|
||||
else ifeq ($(UNAME_M),i86pc)
|
||||
BINARY := $(BINARY64)
|
||||
else ifeq ($(UNAME_M),s390x)
|
||||
BINARY := $(BINARYS390)
|
||||
else ifeq ($(UNAME_M),i686)
|
||||
@@ -114,6 +117,19 @@ generate:
|
||||
build:
|
||||
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:
|
||||
# Make sure that the tests pass and the build works
|
||||
TAGS=tcell make test
|
||||
@@ -192,15 +208,15 @@ bin/fzf: target/$(BINARY) | bin
|
||||
cp -f target/$(BINARY) bin/fzf
|
||||
|
||||
docker:
|
||||
docker build -t fzf-ubuntu .
|
||||
docker run -it fzf-ubuntu tmux
|
||||
$(DOCKER) build -t fzf-ubuntu .
|
||||
$(DOCKER) run -it fzf-ubuntu tmux
|
||||
|
||||
docker-test:
|
||||
docker build -t fzf-ubuntu .
|
||||
docker run -it fzf-ubuntu
|
||||
$(DOCKER) build -t fzf-ubuntu .
|
||||
$(DOCKER) run -it fzf-ubuntu
|
||||
|
||||
update:
|
||||
$(GO) get -u
|
||||
$(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
|
||||
|
||||
+1
-1
@@ -493,4 +493,4 @@ autocmd FileType fzf set laststatus=0 noshowmode noruler
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2013-2025 Junegunn Choi
|
||||
Copyright (c) 2013-2026 Junegunn Choi
|
||||
|
||||
+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.73.1
|
||||
```
|
||||
|
||||
`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.
|
||||
+7
-8
@@ -159,11 +159,11 @@ fi
|
||||
set -e
|
||||
|
||||
# Clean up named pipes on exit
|
||||
id=$RANDOM
|
||||
argsf="${TMPDIR:-/tmp}/fzf-args-$id"
|
||||
fifo1="${TMPDIR:-/tmp}/fzf-fifo1-$id"
|
||||
fifo2="${TMPDIR:-/tmp}/fzf-fifo2-$id"
|
||||
fifo3="${TMPDIR:-/tmp}/fzf-fifo3-$id"
|
||||
tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/fzf-tmux-XXXXXX")
|
||||
argsf="$tmpdir/args"
|
||||
fifo1="$tmpdir/fifo1"
|
||||
fifo2="$tmpdir/fifo2"
|
||||
fifo3="$tmpdir/fifo3"
|
||||
if tmux_win_opts=$(tmux show-options -p remain-on-exit \; show-options -p synchronize-panes 2> /dev/null); then
|
||||
tmux_win_opts=($(sed '/ off/d; s/synchronize-panes/set-option -p synchronize-panes/; s/remain-on-exit/set-option -p remain-on-exit/; s/$/ \\;/' <<< "$tmux_win_opts"))
|
||||
tmux_off_opts='; set-option -p synchronize-panes off ; set-option -p remain-on-exit off'
|
||||
@@ -172,8 +172,7 @@ else
|
||||
tmux_off_opts='; set-window-option synchronize-panes off ; set-window-option remain-on-exit off'
|
||||
fi
|
||||
cleanup() {
|
||||
\rm -f $argsf $fifo1 $fifo2 $fifo3
|
||||
|
||||
\rm -rf "$tmpdir"
|
||||
# Restore tmux window options
|
||||
if [[ ${#tmux_win_opts[@]} -gt 1 ]]; then
|
||||
eval "tmux ${tmux_win_opts[*]}"
|
||||
@@ -196,7 +195,7 @@ cleanup() {
|
||||
trap 'cleanup 1' SIGUSR1
|
||||
trap 'cleanup' EXIT
|
||||
|
||||
envs="export TERM=$TERM "
|
||||
envs="export TERM=$(printf %q "$TERM") "
|
||||
if [[ $opt =~ "-E" ]]; then
|
||||
if [[ $tmux_version == 3.2 ]]; then
|
||||
FZF_DEFAULT_OPTS="--margin 0,1 $FZF_DEFAULT_OPTS"
|
||||
|
||||
+3
-5
@@ -112,7 +112,7 @@ the whole if we start off with `:FZF` command.
|
||||
" Bang version starts fzf in fullscreen mode
|
||||
: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
|
||||
vertical splits respectively.
|
||||
|
||||
@@ -218,7 +218,6 @@ list:
|
||||
`fg` / `bg` / `hl` | Item (foreground / background / highlight)
|
||||
`fg+` / `bg+` / `hl+` | Current item (foreground / background / highlight)
|
||||
`preview-fg` / `preview-bg` | Preview window text and background
|
||||
`hl` / `hl+` | Highlighted substrings (normal / current)
|
||||
`gutter` | Background of the gutter on the left
|
||||
`pointer` | Pointer to the current line ( `>` )
|
||||
`marker` | Multi-select marker ( `>` )
|
||||
@@ -229,7 +228,6 @@ list:
|
||||
`query` | Query string
|
||||
`disabled` | Query string when search is disabled
|
||||
`prompt` | Prompt before query ( `> ` )
|
||||
`pointer` | Pointer to the current line ( `>` )
|
||||
----------------------------+------------------------------------------------------
|
||||
- `component` specifies the component (`fg` / `bg`) from which to extract the
|
||||
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
|
||||
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:
|
||||
>
|
||||
:echo fzf#wrap()
|
||||
@@ -503,7 +501,7 @@ LICENSE *fzf-license*
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2013-2025 Junegunn Choi
|
||||
Copyright (c) 2013-2026 Junegunn Choi
|
||||
|
||||
==============================================================================
|
||||
vim:tw=78:sw=2:ts=2:ft=help:norl:nowrap:
|
||||
|
||||
@@ -4,7 +4,7 @@ require (
|
||||
github.com/charlievieth/fastwalk v1.0.14
|
||||
github.com/gdamore/tcell/v2 v2.9.0
|
||||
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.22
|
||||
github.com/rivo/uniseg v0.4.7
|
||||
golang.org/x/sys v0.35.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/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/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
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/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-20220722155257-8c9f86f7a55f/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/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
set -u
|
||||
|
||||
version=0.67.0
|
||||
version=0.73.1
|
||||
auto_completion=
|
||||
key_bindings=
|
||||
update_config=2
|
||||
shells="bash zsh fish"
|
||||
shells="bash zsh fish nushell"
|
||||
prefix='~/.fzf'
|
||||
prefix_expand=~/.fzf
|
||||
fish_dir=${XDG_CONFIG_HOME:-$HOME/.config}/fish
|
||||
@@ -27,6 +27,7 @@ usage: $0 [OPTIONS]
|
||||
--no-bash Do not set up bash configuration
|
||||
--no-zsh Do not set up zsh configuration
|
||||
--no-fish Do not set up fish configuration
|
||||
--no-nushell Do not set up nushell configuration
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -56,6 +57,7 @@ for opt in "$@"; do
|
||||
--no-bash) shells=${shells/bash/} ;;
|
||||
--no-zsh) shells=${shells/zsh/} ;;
|
||||
--no-fish) shells=${shells/fish/} ;;
|
||||
--no-nushell) shells=${shells/nushell/} ;;
|
||||
*)
|
||||
echo "unknown option: $opt"
|
||||
help
|
||||
@@ -112,10 +114,15 @@ link_fzf_in_path() {
|
||||
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() {
|
||||
command -v curl > /dev/null &&
|
||||
if [[ $1 =~ tar.gz$ ]]; then
|
||||
curl -fL $1 | tar --no-same-owner -xzf -
|
||||
curl -fL $1 | tar $tar_opts
|
||||
else
|
||||
local temp=${TMPDIR:-/tmp}/fzf.zip
|
||||
curl -fLo "$temp" $1 && unzip -o "$temp" && rm -f "$temp"
|
||||
@@ -125,7 +132,7 @@ try_curl() {
|
||||
try_wget() {
|
||||
command -v wget > /dev/null &&
|
||||
if [[ $1 =~ tar.gz$ ]]; then
|
||||
wget -O - $1 | tar --no-same-owner -xzf -
|
||||
wget -O - $1 | tar $tar_opts
|
||||
else
|
||||
local temp=${TMPDIR:-/tmp}/fzf.zip
|
||||
wget -O "$temp" $1 && unzip -o "$temp" && rm -f "$temp"
|
||||
@@ -219,12 +226,14 @@ fi
|
||||
[[ $* =~ "--bin" ]] && exit 0
|
||||
|
||||
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/}
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${#shells} -lt 3 ]]; then
|
||||
if [[ -z ${shells// /} ]]; then
|
||||
echo "No shell configuration to be updated."
|
||||
exit 0
|
||||
fi
|
||||
@@ -243,16 +252,17 @@ fi
|
||||
|
||||
echo
|
||||
for shell in $shells; do
|
||||
[[ $shell == nushell ]] && continue
|
||||
fzf_completion="source \"$fzf_base/shell/completion.${shell}\""
|
||||
fzf_key_bindings="source \"$fzf_base/shell/key-bindings.${shell}\""
|
||||
[[ $shell == fish ]] && continue
|
||||
src=${prefix_expand}.${shell}
|
||||
echo -n "Generate $src ... "
|
||||
|
||||
fzf_completion="source \"$fzf_base/shell/completion.${shell}\""
|
||||
if [ $auto_completion -eq 0 ]; then
|
||||
fzf_completion="# $fzf_completion"
|
||||
fi
|
||||
|
||||
fzf_key_bindings="source \"$fzf_base/shell/key-bindings.${shell}\""
|
||||
if [ $key_bindings -eq 0 ]; then
|
||||
fzf_key_bindings="# $fzf_key_bindings"
|
||||
fi
|
||||
@@ -302,15 +312,16 @@ append_line() {
|
||||
line="$2"
|
||||
file="$3"
|
||||
pat="${4:-}"
|
||||
at_lno="${5:-}"
|
||||
lines=""
|
||||
|
||||
echo "Update $file:"
|
||||
echo " - $line"
|
||||
if [ -f "$file" ]; then
|
||||
if [ $# -lt 4 ]; then
|
||||
lines=$(\grep -nF "$line" "$file")
|
||||
else
|
||||
if [[ -n $pat ]]; then
|
||||
lines=$(\grep -nF "$pat" "$file")
|
||||
else
|
||||
lines=$(\grep -nF "${line#"${line%%[![:space:]]*}"}" "$file")
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -328,8 +339,12 @@ append_line() {
|
||||
|
||||
set -e
|
||||
if [ "$update" -eq 1 ]; then
|
||||
[ -f "$file" ] && echo >> "$file"
|
||||
echo "$line" >> "$file"
|
||||
if [[ -z $at_lno ]]; then
|
||||
[ -f "$file" ] && echo >> "$file"
|
||||
echo "$line" >> "$file"
|
||||
else
|
||||
sed -i.~fzf_bak "${at_lno}a\\"$'\n'"$line" "$file" && rm "$file.~fzf_bak"
|
||||
fi
|
||||
echo " + Added"
|
||||
else
|
||||
echo " ~ Skipped"
|
||||
@@ -358,33 +373,94 @@ fi
|
||||
echo
|
||||
for shell in $shells; do
|
||||
[[ $shell == fish ]] && continue
|
||||
[[ $shell == nushell ]] && continue
|
||||
[ $shell = zsh ] && dest=${ZDOTDIR:-~}/.zshrc || dest=~/.bashrc
|
||||
append_line $update_config "[ -f ${prefix}.${shell} ] && source ${prefix}.${shell}" "$dest" "${prefix}.${shell}"
|
||||
done
|
||||
|
||||
if [ $key_bindings -eq 1 ] && [[ $shells =~ fish ]]; then
|
||||
if [[ $shells =~ fish ]]; then
|
||||
bind_file="${fish_dir}/functions/fish_user_key_bindings.fish"
|
||||
if [ ! -e "$bind_file" ]; then
|
||||
mkdir -p "${fish_dir}/functions"
|
||||
create_file "$bind_file" \
|
||||
'function fish_user_key_bindings' \
|
||||
' fzf --fish | source' \
|
||||
'end'
|
||||
if [[ $key_bindings -eq 1 || $auto_completion -eq 1 ]]; then
|
||||
mkdir -p "${fish_dir}/functions"
|
||||
if [[ $key_bindings -eq 1 && $auto_completion -eq 1 ]]; then
|
||||
create_file "$bind_file" \
|
||||
'function fish_user_key_bindings' \
|
||||
' fzf --fish | source' \
|
||||
'end'
|
||||
elif [[ $key_bindings -eq 1 ]]; then
|
||||
create_file "$bind_file" \
|
||||
'function fish_user_key_bindings' \
|
||||
" $fzf_key_bindings" \
|
||||
'end'
|
||||
elif [[ $auto_completion -eq 1 ]]; then
|
||||
create_file "$bind_file" \
|
||||
'function fish_user_key_bindings' \
|
||||
" $fzf_completion" \
|
||||
'end'
|
||||
fi
|
||||
lno_func=$(\grep -nF "function fish_user_key_bindings" "$bind_file" | sed 's/:.*//' | tr '\n' ' ')
|
||||
else
|
||||
lno_func=0
|
||||
fi
|
||||
else
|
||||
echo "Check $bind_file:"
|
||||
lno=$(\grep -nF "fzf_key_bindings" "$bind_file" | sed 's/:.*//' | tr '\n' ' ')
|
||||
if [[ -n $lno ]]; then
|
||||
echo " ** Found 'fzf_key_bindings' in line #$lno"
|
||||
echo " ** You have to replace the line to 'fzf --fish | source'"
|
||||
lno_func=$(\grep -nF "function fish_user_key_bindings" "$bind_file" | sed 's/:.*//' | tr '\n' ' ')
|
||||
if [[ -z $lno_func ]]; then
|
||||
echo -e "function fish_user_key_bindings\nend" >> "$bind_file"
|
||||
lno_func=$(\grep -nF "function fish_user_key_bindings" "$bind_file" | sed 's/:.*//' | tr '\n' ' ')
|
||||
fi
|
||||
lno_keys=$(\grep -nF "fzf_key_bindings" "$bind_file" | sed 's/:.*//' | tr '\n' ' ')
|
||||
if [[ -n $lno_keys ]]; then
|
||||
echo " ** Found 'fzf_key_bindings' in line #$lno_keys"
|
||||
if [[ $key_bindings -eq 1 && $auto_completion -eq 1 ]]; then
|
||||
echo " ** You have to replace the line to 'fzf --fish | source'"
|
||||
elif [[ $key_bindings -eq 1 ]]; then
|
||||
echo " ** You have to replace the line to '$fzf_key_bindings'"
|
||||
else
|
||||
echo " ** You have to remove the line"
|
||||
fi
|
||||
echo
|
||||
else
|
||||
echo " - Clear"
|
||||
echo
|
||||
append_line $update_config "fzf --fish | source" "$bind_file"
|
||||
if [[ $key_bindings -eq 1 && $auto_completion -eq 1 ]]; then
|
||||
sed -i.~fzf_bak "\#$fzf_completion#d" "$bind_file" && rm "$bind_file.~fzf_bak"
|
||||
sed -i.~fzf_bak "\#$fzf_key_bindings#d" "$bind_file" && rm "$bind_file.~fzf_bak"
|
||||
append_line $update_config " fzf --fish | source" "$bind_file" "" "$lno_func"
|
||||
else
|
||||
sed -i.~fzf_bak '/fzf --fish \| source/d' "$bind_file" && rm "$bind_file.~fzf_bak"
|
||||
if [[ $key_bindings -eq 1 ]]; then
|
||||
sed -i.~fzf_bak "\#$fzf_completion#d" "$bind_file" && rm "$bind_file.~fzf_bak"
|
||||
append_line $update_config " $fzf_key_bindings" "$bind_file" "" "$lno_func"
|
||||
elif [[ $auto_completion -eq 1 ]]; then
|
||||
sed -i.~fzf_bak "\#$fzf_key_bindings#d" "$bind_file" && rm "$bind_file.~fzf_bak"
|
||||
append_line $update_config " $fzf_completion" "$bind_file" "" "$lno_func"
|
||||
fi
|
||||
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
|
||||
echo 'Finished. Restart your shell or reload config file.'
|
||||
if [[ $shells =~ bash ]]; then
|
||||
@@ -393,7 +469,8 @@ if [ $update_config -eq 1 ]; then
|
||||
echo
|
||||
fi
|
||||
[[ $shells =~ zsh ]] && echo " source ${ZDOTDIR:-~}/.zshrc # zsh"
|
||||
[[ $shells =~ fish ]] && [ $key_bindings -eq 1 ] && echo ' fzf_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 'Use uninstall script to remove fzf.'
|
||||
echo
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
$version="0.67.0"
|
||||
$version="0.73.1"
|
||||
|
||||
$fzf_base=Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/junegunn/fzf/src/protector"
|
||||
)
|
||||
|
||||
var version = "0.67"
|
||||
var version = "0.73"
|
||||
var revision = "devel"
|
||||
|
||||
//go:embed shell/key-bindings.bash
|
||||
@@ -29,6 +29,15 @@ var zshCompletion []byte
|
||||
//go:embed shell/key-bindings.fish
|
||||
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
|
||||
var fishCompletion []byte
|
||||
|
||||
//go:embed man/man1/fzf.1
|
||||
var manPage []byte
|
||||
|
||||
@@ -65,7 +74,12 @@ func main() {
|
||||
}
|
||||
if options.Fish {
|
||||
printScript("key-bindings.fish", fishKeyBindings)
|
||||
fmt.Println("fzf_key_bindings")
|
||||
printScript("completion.fish", fishCompletion)
|
||||
return
|
||||
}
|
||||
if options.Nushell {
|
||||
printScript("key-bindings.nu", nushellKeyBindings)
|
||||
printScript("completion.nu", nushellCompletion)
|
||||
return
|
||||
}
|
||||
if options.Help {
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
.ig
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2013-2025 Junegunn Choi
|
||||
Copyright (c) 2013-2026 Junegunn Choi
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
@@ -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
|
||||
THE SOFTWARE.
|
||||
..
|
||||
.TH fzf\-tmux 1 "Nov 2025" "fzf 0.67.0" "fzf\-tmux - open fzf in tmux split pane"
|
||||
.TH fzf\-tmux 1 "May 2026" "fzf 0.73.1" "fzf\-tmux - open fzf in tmux split pane"
|
||||
|
||||
.SH NAME
|
||||
fzf\-tmux - open fzf in tmux split pane
|
||||
|
||||
+182
-30
@@ -1,7 +1,7 @@
|
||||
.ig
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2013-2025 Junegunn Choi
|
||||
Copyright (c) 2013-2026 Junegunn Choi
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
@@ -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
|
||||
THE SOFTWARE.
|
||||
..
|
||||
.TH fzf 1 "Nov 2025" "fzf 0.67.0" "fzf - a command-line fuzzy finder"
|
||||
.TH fzf 1 "May 2026" "fzf 0.73.1" "fzf - a command-line fuzzy finder"
|
||||
|
||||
.SH NAME
|
||||
fzf - a command-line fuzzy finder
|
||||
@@ -134,6 +134,14 @@ e.g.
|
||||
# Use template to rearrange fields
|
||||
echo foo,bar,baz | fzf --delimiter , --with-nth '{n},{1},{3},{2},{1..2}'
|
||||
.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
|
||||
.BI "\-\-accept\-nth=" "N[,..] or TEMPLATE"
|
||||
Define which fields to print on accept. The last delimiter is stripped from the
|
||||
@@ -272,6 +280,7 @@ color mappings. Each entry is separated by a comma and/or whitespaces.
|
||||
\fBgutter \fRGutter on the left
|
||||
\fBcurrent\-hl (hl+) \fRHighlighted substrings (current line)
|
||||
\fBalt\-bg \fRAlternate background color to create striped lines
|
||||
\fBalt\-gutter \fRAlternate gutter color to create the striped pattern
|
||||
\fBquery (input\-fg) \fRQuery string
|
||||
\fBghost \fRGhost text (\fB\-\-ghost\fR, \fBdim\fR applied by default)
|
||||
\fBdisabled \fRQuery string when search is disabled (\fB\-\-disabled\fR)
|
||||
@@ -325,10 +334,14 @@ color mappings. Each entry is separated by a comma and/or whitespaces.
|
||||
\fB#rrggbb \fR24-bit colors
|
||||
|
||||
.B ANSI ATTRIBUTES: (Only applies to foreground colors)
|
||||
\fBregular \fRClear previously set attributes; should precede the other ones
|
||||
\fBstrip \fRRemove colors
|
||||
\fBregular \fRClear previously set attributes; should precede the other ones
|
||||
\fBstrip \fRRemove colors
|
||||
\fBbold\fR
|
||||
\fBunderline\fR
|
||||
\fBunderline-double\fR
|
||||
\fBunderline-curly\fR
|
||||
\fBunderline-dotted\fR
|
||||
\fBunderline-dashed\fR
|
||||
\fBreverse\fR
|
||||
\fBdim\fR
|
||||
\fBitalic\fR
|
||||
@@ -371,7 +384,7 @@ Use black background
|
||||
|
||||
.SS DISPLAY MODE
|
||||
.TP
|
||||
.BI "\-\-height=" "[~]HEIGHT[%]"
|
||||
.BI "\-\-height=" "[~][\-]HEIGHT[%]"
|
||||
Display fzf window below the cursor with the given height instead of using
|
||||
the full screen.
|
||||
|
||||
@@ -381,17 +394,19 @@ height minus the given value.
|
||||
fzf \-\-height=\-1
|
||||
|
||||
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
|
||||
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:
|
||||
.br
|
||||
* Cannot be used with top/bottom margin and padding given in percent size
|
||||
.br
|
||||
* Negative value is not allowed
|
||||
.br
|
||||
* It will not find the right size when there are multi-line items
|
||||
|
||||
.TP
|
||||
@@ -402,25 +417,26 @@ layout options so that the specified number of items are visible in the list
|
||||
section (default: \fB10+\fR).
|
||||
Ignored when \fB\-\-height\fR is not specified or set as an absolute value.
|
||||
.TP
|
||||
.BI "\-\-tmux" "[=[center|top|bottom|left|right][,SIZE[%]][,SIZE[%]][,border-native]]"
|
||||
Start fzf in a tmux popup (default \fBcenter,50%\fR). Requires tmux 3.3 or
|
||||
later. This option is ignored if you are not running fzf inside tmux.
|
||||
.BI "\-\-popup" "[=[center|top|bottom|left|right][,SIZE[%]][,SIZE[%]][,border-native]]"
|
||||
Start fzf in a tmux popup or in a Zellij floating pane (default
|
||||
\fBcenter,50%\fR). 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.
|
||||
|
||||
e.g.
|
||||
\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
|
||||
fzf \-\-tmux right,40%
|
||||
fzf \-\-popup right,40%
|
||||
|
||||
# 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
|
||||
fzf \-\-tmux top,80%,40%
|
||||
fzf \-\-popup top,80%,40%
|
||||
|
||||
# Popup with a native tmux border in the center with 80% width and height
|
||||
fzf \-\-tmux center,80%,border\-native\fR
|
||||
# Popup with a native tmux or Zellij border in the center with 80% width and height
|
||||
fzf \-\-popup center,80%,border\-native\fR
|
||||
|
||||
.SS LAYOUT
|
||||
.TP
|
||||
@@ -501,6 +517,8 @@ Draw border around the finder
|
||||
.br
|
||||
.BR double " Border with double lines"
|
||||
.br
|
||||
.BR dashed " Border with dashed lines and rounded corners"
|
||||
.br
|
||||
.BR block " Border using block elements; suitable when using different background colors"
|
||||
.br
|
||||
.BR thinblock " Border using legacy computing symbols; may not be displayed on some terminals"
|
||||
@@ -588,8 +606,11 @@ Highlight the whole current line
|
||||
.B "\-\-cycle"
|
||||
Enable cyclic scroll
|
||||
.TP
|
||||
.B "\-\-wrap"
|
||||
Enable line wrap
|
||||
.BI "\-\-wrap" "[=MODE]"
|
||||
Enable line wrap. \fIMODE\fR can be \fBchar\fR (default) or \fBword\fR.
|
||||
\fBword\fR mode wraps lines at word boundaries (spaces and tabs) instead of
|
||||
at arbitrary character positions. \fB\-\-wrap\-word\fR is a synonym for
|
||||
\fB\-\-wrap=word\fR.
|
||||
.TP
|
||||
.BI "\-\-wrap\-sign" "=INDICATOR"
|
||||
Indicator for wrapped lines. The default is '↳ ' or '> ' depending on
|
||||
@@ -601,17 +622,53 @@ Disable multi-line display of items when using \fB\-\-read0\fR
|
||||
.B "\-\-raw"
|
||||
Enable raw mode where non-matching items are also displayed in a dimmed color.
|
||||
.TP
|
||||
.B "\-\-track"
|
||||
.BI "\-\-track"
|
||||
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
|
||||
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
|
||||
option.
|
||||
can be confusing.
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
\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
|
||||
.TP
|
||||
.B "\-\-tac"
|
||||
@@ -740,7 +797,7 @@ ENVIRONMENT VARIABLES EXPORTED TO CHILD PROCESSES.
|
||||
|
||||
e.g.
|
||||
\fB# Prepend the current cursor position in yellow
|
||||
fzf \-\-info\-command='echo \-e "\\x1b[33;1m$FZF_POS\\x1b[m/$FZF_INFO 💛"'\fR
|
||||
fzf \-\-info\-command='printf "\\x1b[33;1m$FZF_POS\\x1b[m/$FZF_INFO 💛"'\fR
|
||||
|
||||
.TP
|
||||
.B "\-\-no\-info"
|
||||
@@ -840,6 +897,9 @@ e.g.
|
||||
# This won't work properly without 'f' flag due to ARG_MAX limit.
|
||||
seq 100000 | fzf \-\-preview "awk '{sum+=\\$1} END {print sum}' {*f}"\fR
|
||||
|
||||
\fB# Use {+f} to get the selected items as a line-separated list
|
||||
seq 100 | fzf \-\-multi \-\-bind 'enter:become:cat {+f}'\fR
|
||||
|
||||
Also,
|
||||
|
||||
* \fB{q}\fR is replaced to the current query string
|
||||
@@ -897,6 +957,8 @@ Should be used with one of the following \fB\-\-preview\-window\fR options.
|
||||
.br
|
||||
.B * border\-double
|
||||
.br
|
||||
.B * border\-dashed
|
||||
.br
|
||||
.B * border\-block
|
||||
.br
|
||||
.B * border\-thinblock
|
||||
@@ -908,6 +970,11 @@ Should be used with one of the following \fB\-\-preview\-window\fR options.
|
||||
.B * border\-bottom
|
||||
.br
|
||||
|
||||
.TP
|
||||
.BI "\-\-preview\-wrap\-sign" =INDICATOR
|
||||
Indicator for wrapped lines in the preview window. If not set, the value of
|
||||
\fB\-\-wrap\-sign\fR is used.
|
||||
|
||||
.TP
|
||||
.BI "\-\-preview\-label\-pos" [=N[:top|bottom]]
|
||||
Position of the border label on the border line of the preview window. Specify
|
||||
@@ -918,7 +985,7 @@ default value 0 (or \fBcenter\fR) will put the label at the center of the
|
||||
border line.
|
||||
|
||||
.TP
|
||||
.BI "\-\-preview\-window=" "[POSITION][,SIZE[%]][,border\-STYLE][,[no]wrap][,[no]follow][,[no]cycle][,[no]info][,[no]hidden][,+SCROLL[OFFSETS][/DENOM]][,~HEADER_LINES][,default][,<SIZE_THRESHOLD(ALTERNATIVE_LAYOUT)]"
|
||||
.BI "\-\-preview\-window=" "[POSITION][,SIZE[%]][,border\-STYLE][,[no]wrap][,wrap\-word][,[no]follow][,[no]cycle][,[no]info][,[no]hidden][,+SCROLL[OFFSETS][/DENOM]][,~HEADER_LINES][,default][,<SIZE_THRESHOLD(ALTERNATIVE_LAYOUT)]"
|
||||
|
||||
.RS
|
||||
.B POSITION: (default: right)
|
||||
@@ -926,9 +993,14 @@ border line.
|
||||
\fBdown
|
||||
\fBleft
|
||||
\fBright
|
||||
\fBnext
|
||||
|
||||
\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
|
||||
default until \fBtoggle\-preview\fR action is triggered.
|
||||
|
||||
@@ -936,7 +1008,8 @@ default until \fBtoggle\-preview\fR action is triggered.
|
||||
execute the command in the background.
|
||||
|
||||
* Long lines are truncated by default. Line wrap can be enabled with
|
||||
\fBwrap\fR flag.
|
||||
\fBwrap\fR flag. \fBwrap\-word\fR flag enables word-level wrapping, which
|
||||
breaks lines at word boundaries instead of mid-word.
|
||||
|
||||
* Preview window will automatically scroll to the bottom when \fBfollow\fR
|
||||
flag is set, similarly to how \fBtail \-f\fR works.
|
||||
@@ -1036,7 +1109,17 @@ Print header before the prompt line. When both normal header and header lines
|
||||
.TP
|
||||
.BI "\-\-header\-border" [=STYLE]
|
||||
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
|
||||
.BI "\-\-header\-label" [=LABEL]
|
||||
@@ -1052,6 +1135,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
|
||||
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.
|
||||
\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
|
||||
|
||||
@@ -1065,7 +1152,10 @@ are not affected by \fB\-\-with\-nth\fR. ANSI color codes are processed even whe
|
||||
.TP
|
||||
.BI "\-\-footer\-border" [=STYLE]
|
||||
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
|
||||
.BI "\-\-footer\-label" [=LABEL]
|
||||
@@ -1212,6 +1302,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
|
||||
\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
|
||||
.TP
|
||||
.B "\-\-walker=[file][,dir][,follow][,hidden]"
|
||||
@@ -1271,6 +1373,12 @@ Print script to set up Fish shell integration
|
||||
|
||||
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
|
||||
.TP
|
||||
.B "\-\-no\-mouse"
|
||||
@@ -1373,12 +1481,18 @@ fzf exports the following environment variables to its child processes.
|
||||
.br
|
||||
.BR FZF_POS " Vertical position of the cursor in the list starting from 1"
|
||||
.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
|
||||
.BR FZF_QUERY " Current query string"
|
||||
.br
|
||||
.BR FZF_INPUT_STATE " Current input state (enabled, disabled, hidden)"
|
||||
.br
|
||||
.BR FZF_NTH " Current \-\-nth option"
|
||||
.br
|
||||
.BR FZF_WITH_NTH " Current \-\-with\-nth option"
|
||||
.br
|
||||
.BR FZF_PROMPT " Prompt string"
|
||||
.br
|
||||
.BR FZF_GHOST " Ghost string"
|
||||
@@ -1399,6 +1513,10 @@ fzf exports the following environment variables to its child processes.
|
||||
.br
|
||||
.BR FZF_KEY " The name of the last key pressed"
|
||||
.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
|
||||
.BR FZF_SOCK " Unix socket path when \-\-listen option is used"
|
||||
@@ -1413,6 +1531,10 @@ fzf exports the following environment variables to its child processes.
|
||||
.br
|
||||
.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.
|
||||
|
||||
.SH EXTENDED SEARCH MODE
|
||||
|
||||
Unless specified otherwise, fzf will start in "extended\-search mode". In this
|
||||
@@ -1838,6 +1960,30 @@ variables starting from 1. It optionally sets \fBFZF_CLICK_FOOTER_WORD\fR
|
||||
if clicked on a word.
|
||||
.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:
|
||||
A key or an event can be bound to one or more of the following actions.
|
||||
|
||||
@@ -1862,12 +2008,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\-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\-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\-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\-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\-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\-preview(...)\fR (change \fB\-\-preview\fR option)
|
||||
\fBchange\-preview\-label(...)\fR (change \fB\-\-preview\-label\fR to the given string)
|
||||
@@ -1953,12 +2101,14 @@ A key or an event can be bound to one or more of the following actions.
|
||||
\fBtoggle\-multi\-line\fR
|
||||
\fBtoggle\-preview\fR
|
||||
\fBtoggle\-preview\-wrap\fR
|
||||
\fBtoggle\-preview\-wrap\-word\fR
|
||||
\fBtoggle\-raw\fR (toggle raw mode for displaying non-matching items)
|
||||
\fBtoggle\-search\fR (toggle search functionality)
|
||||
\fBtoggle\-sort\fR
|
||||
\fBtoggle\-track\fR (toggle global tracking option (\fB\-\-track\fR))
|
||||
\fBtoggle\-track\-current\fR (toggle tracking of the current item)
|
||||
\fBtoggle\-wrap\fR \fIctrl\-/\fR \fIalt\-/\fR
|
||||
\fBtoggle\-wrap\fR
|
||||
\fBtoggle\-wrap\-word\fR \fIctrl\-/\fR \fIalt\-/\fR
|
||||
\fBtoggle+down\fR \fIctrl\-i (tab)\fR
|
||||
\fBtoggle+up\fR \fIbtab (shift\-tab)\fR
|
||||
\fBtrack\-current\fR (track the current item; automatically disabled if focus changes)
|
||||
@@ -1966,10 +2116,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\-ghost(...)\fR (transform ghost text 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\-input\-label(...)\fR (transform input 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\-with\-nth(...)\fR (transform with-nth 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\-prompt(...)\fR (transform prompt string using an external command)
|
||||
@@ -2105,7 +2257,7 @@ payload of HTTP POST request to the \fB\-\-listen\fR server.
|
||||
|
||||
e.g.
|
||||
\fB# Disallow selecting an empty line
|
||||
echo \-e "1. Hello\\n2. Goodbye\\n\\n3. Exit" |
|
||||
printf "1. Hello\\n2. Goodbye\\n\\n3. Exit" |
|
||||
fzf \-\-height '~100%' \-\-reverse \-\-header 'Select one' \\
|
||||
\-\-bind 'enter:transform:[[ \-n {} ]] &&
|
||||
echo accept ||
|
||||
|
||||
+58
-26
@@ -1,4 +1,4 @@
|
||||
" Copyright (c) 2013-2025 Junegunn Choi
|
||||
" Copyright (c) 2013-2026 Junegunn Choi
|
||||
"
|
||||
" MIT License
|
||||
"
|
||||
@@ -896,6 +896,7 @@ function! s:execute_term(dict, command, temps) abort
|
||||
endif
|
||||
endfunction
|
||||
function! fzf.on_exit(id, code, ...)
|
||||
silent! autocmd! fzf_popup_resize
|
||||
if s:getpos() == self.ppos " {'window': 'enew'}
|
||||
for [opt, val] in items(self.winopts)
|
||||
execute 'let' opt '=' val
|
||||
@@ -1023,15 +1024,17 @@ function! s:callback(dict, lines) abort
|
||||
endfunction
|
||||
|
||||
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 opts = extend({'relative': 'editor', 'style': 'minimal'}, a:opts)
|
||||
let win = nvim_open_win(buf, v:true, opts)
|
||||
call setwinvar(win, '&colorcolumn', '')
|
||||
let s:popup_id = nvim_open_win(buf, v:true, opts)
|
||||
call setwinvar(s:popup_id, '&colorcolumn', '')
|
||||
|
||||
" Colors
|
||||
try
|
||||
call setwinvar(win, '&winhighlight', 'Pmenu:,Normal:Normal')
|
||||
call setwinvar(s:popup_id, '&winhighlight', 'Pmenu:,Normal:Normal')
|
||||
let rules = get(g:, 'fzf_colors', {})
|
||||
if has_key(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 hl = nvim_set_hl(ns, 'Normal',
|
||||
\ &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
|
||||
catch
|
||||
endtry
|
||||
return buf
|
||||
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
|
||||
function! s:create_popup(opts) abort
|
||||
let s:popup_create = {buf -> popup_create(buf, #{
|
||||
\ line: a:opts.row,
|
||||
\ col: a:opts.col,
|
||||
\ minwidth: a:opts.width,
|
||||
\ maxwidth: a:opts.width,
|
||||
\ minheight: a:opts.height,
|
||||
\ maxheight: a:opts.height,
|
||||
\ zindex: 1000,
|
||||
\ })}
|
||||
function! s:create_popup() abort
|
||||
function! s:popup_create(buf)
|
||||
let s:popup_id = popup_create(a:buf, #{zindex: 1000})
|
||||
call s:resize_popup()
|
||||
endfunction
|
||||
autocmd TerminalOpen * ++once call s:popup_create(str2nr(expand('<abuf>')))
|
||||
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
|
||||
|
||||
function! s:popup(opts) abort
|
||||
let xoffset = get(a:opts, 'xoffset', 0.5)
|
||||
let yoffset = get(a:opts, 'yoffset', 0.5)
|
||||
let relative = get(a:opts, 'relative', 0)
|
||||
function! s:popup_bounds() abort
|
||||
let opts = s:popup_opts
|
||||
|
||||
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
|
||||
let columns = relative ? winwidth(0) : &columns
|
||||
let lines = relative ? winheight(0) : (&lines - has('nvim'))
|
||||
|
||||
" Size and position
|
||||
let width = min([max([8, a:opts.width > 1 ? a:opts.width : float2nr(columns * a:opts.width)]), columns])
|
||||
let height = min([max([4, a:opts.height > 1 ? a:opts.height : float2nr(lines * a:opts.height)]), lines])
|
||||
let width = min([max([8, opts.width > 1 ? opts.width : float2nr(columns * opts.width)]), columns])
|
||||
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 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 col += !has('nvim')
|
||||
|
||||
call s:create_popup({
|
||||
\ 'row': row, 'col': col, 'width': width, 'height': height
|
||||
\ })
|
||||
return { '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
|
||||
|
||||
let s:default_action = {
|
||||
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
__fzf_defaults() {
|
||||
# $1: Prepend 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
|
||||
printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
|
||||
builtin printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
|
||||
}
|
||||
|
||||
__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}
|
||||
# }
|
||||
# }
|
||||
+71
-24
@@ -4,8 +4,6 @@
|
||||
# / __/ / /_/ __/
|
||||
# /_/ /___/_/ completion.bash
|
||||
#
|
||||
# - $FZF_TMUX (default: 0)
|
||||
# - $FZF_TMUX_OPTS (default: empty)
|
||||
# - $FZF_COMPLETION_TRIGGER (default: '**')
|
||||
# - $FZF_COMPLETION_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.
|
||||
|
||||
__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
|
||||
printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
|
||||
builtin printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
|
||||
}
|
||||
|
||||
__fzf_exec_awk() {
|
||||
@@ -81,7 +79,7 @@ __fzf_orig_completion() {
|
||||
f="${BASH_REMATCH[2]}"
|
||||
cmd="${BASH_REMATCH[3]}"
|
||||
[[ $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
|
||||
__fzf_nospace_commands="${__fzf_nospace_commands-} $cmd "
|
||||
fi
|
||||
@@ -111,7 +109,7 @@ __fzf_orig_completion_instantiate() {
|
||||
orig="${!orig_var-}"
|
||||
orig="${orig%#*}"
|
||||
[[ $orig == *' %s '* ]] || return 1
|
||||
printf -v REPLY "$orig" "$func"
|
||||
builtin printf -v REPLY "$orig" "$func"
|
||||
}
|
||||
|
||||
_fzf_opts_completion() {
|
||||
@@ -124,6 +122,7 @@ _fzf_opts_completion() {
|
||||
+i --no-ignore-case
|
||||
+s --no-sort
|
||||
+x --no-extended
|
||||
--accept-nth
|
||||
--ansi
|
||||
--bash
|
||||
--bind
|
||||
@@ -137,56 +136,89 @@ _fzf_opts_completion() {
|
||||
--expect
|
||||
--filepath-word
|
||||
--fish
|
||||
--footer
|
||||
--footer-border
|
||||
--footer-label
|
||||
--footer-label-pos
|
||||
--freeze-left
|
||||
--freeze-right
|
||||
--gap
|
||||
--gap-line
|
||||
--ghost
|
||||
--gutter
|
||||
--gutter-raw
|
||||
--header
|
||||
--header-border
|
||||
--header-first
|
||||
--header-label
|
||||
--header-label-pos
|
||||
--header-lines
|
||||
--header-lines-border
|
||||
--height
|
||||
--highlight-line
|
||||
--history
|
||||
--history-size
|
||||
--hscroll-off
|
||||
--id-nth
|
||||
--info
|
||||
--info-command
|
||||
--input-border
|
||||
--input-label
|
||||
--input-label-pos
|
||||
--jump-labels
|
||||
--keep-right
|
||||
--layout
|
||||
--listen
|
||||
--listen-unsafe
|
||||
--list-border
|
||||
--list-label
|
||||
--list-label-pos
|
||||
--literal
|
||||
--man
|
||||
--margin
|
||||
--marker
|
||||
--marker-multi-line
|
||||
--min-height
|
||||
--no-bold
|
||||
--no-clear
|
||||
--no-hscroll
|
||||
--no-mouse
|
||||
--no-input
|
||||
--no-multi-line
|
||||
--no-scrollbar
|
||||
--no-separator
|
||||
--no-unicode
|
||||
--padding
|
||||
--pointer
|
||||
--preview
|
||||
--preview-border
|
||||
--preview-label
|
||||
--preview-label-pos
|
||||
--preview-window
|
||||
--print-query
|
||||
--print0
|
||||
--prompt
|
||||
--raw
|
||||
--read0
|
||||
--reverse
|
||||
--scheme
|
||||
--scroll-off
|
||||
--scrollbar
|
||||
--separator
|
||||
--smart-case
|
||||
--style
|
||||
--sync
|
||||
--tabstop
|
||||
--tac
|
||||
--tail
|
||||
--tiebreak
|
||||
--tmux
|
||||
--track
|
||||
--version
|
||||
--walker
|
||||
--walker-root
|
||||
--walker-skip
|
||||
--with-nth
|
||||
--with-shell
|
||||
--wrap
|
||||
--wrap-sign
|
||||
--preview-wrap-sign
|
||||
--zsh
|
||||
-0 --exit-0
|
||||
-1 --select-1
|
||||
@@ -206,11 +238,11 @@ _fzf_opts_completion() {
|
||||
return 0
|
||||
;;
|
||||
--tiebreak)
|
||||
COMPREPLY=($(compgen -W "length chunk begin end index" -- "$cur"))
|
||||
COMPREPLY=($(compgen -W "length chunk pathname begin end index" -- "$cur"))
|
||||
return 0
|
||||
;;
|
||||
--color)
|
||||
COMPREPLY=($(compgen -W "dark light 16 bw no" -- "$cur"))
|
||||
COMPREPLY=($(compgen -W "dark light base16 16 bw no" -- "$cur"))
|
||||
return 0
|
||||
;;
|
||||
--layout)
|
||||
@@ -221,12 +253,21 @@ _fzf_opts_completion() {
|
||||
COMPREPLY=($(compgen -W "default right hidden inline inline-right" -- "$cur"))
|
||||
return 0
|
||||
;;
|
||||
--wrap)
|
||||
COMPREPLY=($(compgen -W "char word" -- "$cur"))
|
||||
return 0
|
||||
;;
|
||||
--style)
|
||||
COMPREPLY=($(compgen -W "default minimal full" -- "$cur"))
|
||||
return 0
|
||||
;;
|
||||
--preview-window)
|
||||
COMPREPLY=($(compgen -W "
|
||||
default
|
||||
hidden
|
||||
nohidden
|
||||
wrap
|
||||
wrap-word
|
||||
nowrap
|
||||
cycle
|
||||
nocycle
|
||||
@@ -235,6 +276,7 @@ _fzf_opts_completion() {
|
||||
left
|
||||
right
|
||||
rounded border border-rounded
|
||||
border-line
|
||||
sharp border-sharp
|
||||
border-bold
|
||||
border-block
|
||||
@@ -248,14 +290,16 @@ _fzf_opts_completion() {
|
||||
border-left
|
||||
border-right
|
||||
follow
|
||||
nofollow" -- "$cur"))
|
||||
nofollow
|
||||
info
|
||||
noinfo" -- "$cur"))
|
||||
return 0
|
||||
;;
|
||||
--border)
|
||||
COMPREPLY=($(compgen -W "rounded sharp bold block thinblock double horizontal vertical top bottom left right none" -- "$cur"))
|
||||
--border | --list-border | --header-border | --header-lines-border | --footer-border | --input-border | --preview-border)
|
||||
COMPREPLY=($(compgen -W "line rounded sharp bold block thinblock double horizontal vertical top bottom left right none" -- "$cur"))
|
||||
return 0
|
||||
;;
|
||||
--border-label-pos | --preview-label-pos)
|
||||
--border-label-pos | --preview-label-pos | --list-label-pos | --header-label-pos | --footer-label-pos | --input-label-pos)
|
||||
COMPREPLY=($(compgen -W "center bottom top" -- "$cur"))
|
||||
return 0
|
||||
;;
|
||||
@@ -325,19 +369,22 @@ __fzf_generic_path_completion() {
|
||||
matches=$(
|
||||
export FZF_DEFAULT_OPTS=$(__fzf_defaults "--reverse --scheme=path" "${FZF_COMPLETION_OPTS-} $2")
|
||||
unset FZF_DEFAULT_COMMAND FZF_DEFAULT_OPTS_FILE
|
||||
if [[ $1 =~ dir ]]; then
|
||||
eval "rest=(${FZF_COMPLETION_DIR_OPTS-})"
|
||||
else
|
||||
eval "rest=(${FZF_COMPLETION_PATH_OPTS-})"
|
||||
fi
|
||||
if declare -F "$1" > /dev/null; then
|
||||
eval "$1 $(printf %q "$dir")" | __fzf_comprun "$4" -q "$leftover"
|
||||
eval "$1 $(builtin printf %q "$dir")" | __fzf_comprun "$4" -q "$leftover" "${rest[@]}"
|
||||
else
|
||||
if [[ $1 =~ dir ]]; then
|
||||
walker=dir,follow
|
||||
eval "rest=(${FZF_COMPLETION_DIR_OPTS-})"
|
||||
else
|
||||
walker=file,dir,follow,hidden
|
||||
eval "rest=(${FZF_COMPLETION_PATH_OPTS-})"
|
||||
fi
|
||||
__fzf_comprun "$4" -q "$leftover" --walker "$walker" --walker-root="$dir" "${rest[@]}"
|
||||
fi | while read -r item; do
|
||||
printf "%q " "${item%$3}$3"
|
||||
builtin printf "%q " "${item%$3}$3"
|
||||
done
|
||||
)
|
||||
matches=${matches% }
|
||||
@@ -347,9 +394,9 @@ __fzf_generic_path_completion() {
|
||||
else
|
||||
COMPREPLY=("$cur")
|
||||
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
|
||||
printf '\e[5n'
|
||||
builtin printf '\e[5n'
|
||||
return 0
|
||||
fi
|
||||
dir=$(command dirname "$dir")
|
||||
@@ -407,7 +454,7 @@ _fzf_complete() {
|
||||
COMPREPLY=("$cur")
|
||||
fi
|
||||
bind '"\e[0n": redraw-current-line' 2> /dev/null
|
||||
printf '\e[5n'
|
||||
builtin printf '\e[5n'
|
||||
return 0
|
||||
else
|
||||
_fzf_handle_dynamic_completion "$cmd" "${rest[@]}"
|
||||
@@ -479,7 +526,7 @@ _fzf_proc_completion_post() {
|
||||
# # Set the local attribute for any non-local variable that is set by _known_hosts_real()
|
||||
# local COMPREPLY=()
|
||||
# _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
|
||||
__fzf_list_hosts() {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
# ____ ____
|
||||
# / __/___ / __/
|
||||
# / /_/_ / / /_
|
||||
# / __/ / /_/ __/
|
||||
# /_/ /___/_/ completion.fish
|
||||
#
|
||||
# - $FZF_COMPLETION_OPTS
|
||||
# - $FZF_EXPANSION_OPTS
|
||||
|
||||
# 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
|
||||
# be used anywhere in the script, otherwise the source command will fail.
|
||||
if string match -qr -- '^[12]\\.|^3\\.[0-3]' $version
|
||||
echo "fzf completion script requires fish version 3.4.0 or newer." >&2
|
||||
return 1
|
||||
else if not command -q fzf
|
||||
echo "fzf was not found in path." >&2
|
||||
return 1
|
||||
end
|
||||
|
||||
function fzf_complete -w fzf -d 'fzf command completion and wildcard expansion search'
|
||||
# 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,489 @@
|
||||
# ____ ____
|
||||
# / __/___ / __/
|
||||
# / /_/_ / / /_
|
||||
# / __/ / /_/ __/
|
||||
# /_/ /___/_/ 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
|
||||
| where {|it| ($it | str downcase | str starts-with 'host') or ($it | str downcase | 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
|
||||
#
|
||||
# - $FZF_TMUX (default: 0)
|
||||
# - $FZF_TMUX_OPTS (default: empty)
|
||||
# - $FZF_COMPLETION_TRIGGER (default: '**')
|
||||
# - $FZF_COMPLETION_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.
|
||||
|
||||
__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
|
||||
printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
|
||||
builtin printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
|
||||
}
|
||||
|
||||
__fzf_exec_awk() {
|
||||
@@ -174,15 +172,18 @@ __fzf_generic_path_completion() {
|
||||
export FZF_DEFAULT_OPTS
|
||||
FZF_DEFAULT_OPTS=$(__fzf_defaults "--reverse --scheme=path" "${FZF_COMPLETION_OPTS-}")
|
||||
unset FZF_DEFAULT_COMMAND FZF_DEFAULT_OPTS_FILE
|
||||
if [[ $compgen =~ dir ]]; then
|
||||
rest=${FZF_COMPLETION_DIR_OPTS-}
|
||||
else
|
||||
rest=${FZF_COMPLETION_PATH_OPTS-}
|
||||
fi
|
||||
if declare -f "$compgen" > /dev/null; then
|
||||
eval "$compgen $(printf %q "$dir")" | __fzf_comprun "$cmd_word" ${(Q)${(Z+n+)fzf_opts}} -q "$leftover"
|
||||
eval "$compgen $(printf %q "$dir")" | __fzf_comprun "$cmd_word" ${(Q)${(Z+n+)fzf_opts}} -q "$leftover" ${(Q)${(Z+n+)rest}}
|
||||
else
|
||||
if [[ $compgen =~ dir ]]; then
|
||||
walker=dir,follow
|
||||
rest=${FZF_COMPLETION_DIR_OPTS-}
|
||||
else
|
||||
walker=file,dir,follow,hidden
|
||||
rest=${FZF_COMPLETION_PATH_OPTS-}
|
||||
fi
|
||||
__fzf_comprun "$cmd_word" ${(Q)${(Z+n+)fzf_opts}} -q "$leftover" --walker "$walker" --walker-root="$dir" ${(Q)${(Z+n+)rest}} < /dev/tty
|
||||
fi | while read -r item; do
|
||||
|
||||
+34
-13
@@ -4,7 +4,6 @@
|
||||
# / __/ / /_/ __/
|
||||
# /_/ /___/_/ key-bindings.bash
|
||||
#
|
||||
# - $FZF_TMUX_OPTS
|
||||
# - $FZF_CTRL_T_COMMAND
|
||||
# - $FZF_CTRL_T_OPTS
|
||||
# - $FZF_CTRL_R_COMMAND
|
||||
@@ -25,9 +24,9 @@ if [[ $- =~ i ]]; then
|
||||
# the changes. See code comments in "common.sh" for the implementation details.
|
||||
|
||||
__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
|
||||
printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
|
||||
builtin printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
|
||||
}
|
||||
|
||||
__fzf_exec_awk() {
|
||||
@@ -77,17 +76,35 @@ __fzf_cd__() {
|
||||
) && 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
|
||||
__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{$_}++'
|
||||
output=$(
|
||||
set +o pipefail
|
||||
builtin fc -lnr -2147483648 |
|
||||
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"
|
||||
) || return
|
||||
)
|
||||
__fzf_history_delete "$deletefile"
|
||||
command rm -f "$deletefile"
|
||||
[[ -n $output ]] || return
|
||||
READLINE_LINE=$(command perl -pe 's/^\d*\t//' <<< "$output")
|
||||
if [[ -z $READLINE_POINT ]]; then
|
||||
echo "$READLINE_LINE"
|
||||
@@ -97,7 +114,8 @@ if command -v perl > /dev/null; then
|
||||
}
|
||||
else # awk - fallback for POSIX systems
|
||||
__fzf_history__() {
|
||||
local output script
|
||||
local output script deletefile
|
||||
deletefile=$(mktemp)
|
||||
[[ $(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 } }
|
||||
NR==1 { b = substr($0, 2); next }
|
||||
@@ -108,9 +126,12 @@ else # awk - fallback for POSIX systems
|
||||
set +o pipefail
|
||||
builtin fc -lnr -2147483648 2> /dev/null | # ( $'\t '<lines>$'\n' )* ; <lines> ::= [^\n]* ( $'\n'<lines> )*
|
||||
__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"
|
||||
) || return
|
||||
)
|
||||
__fzf_history_delete "$deletefile"
|
||||
command rm -f "$deletefile"
|
||||
[[ -n $output ]] || return
|
||||
READLINE_LINE=${output#*$'\t'}
|
||||
if [[ -z $READLINE_POINT ]]; then
|
||||
echo "$READLINE_LINE"
|
||||
@@ -121,7 +142,7 @@ else # awk - fallback for POSIX systems
|
||||
fi
|
||||
|
||||
# Required to refresh the prompt after fzf
|
||||
bind -m emacs-standard '"\er": redraw-current-line'
|
||||
bind -m emacs-standard '"\C-\e(": redraw-current-line'
|
||||
|
||||
bind -m vi-command '"\C-z": emacs-editing-mode'
|
||||
bind -m vi-insert '"\C-z": emacs-editing-mode'
|
||||
@@ -130,7 +151,7 @@ bind -m emacs-standard '"\C-z": vi-editing-mode'
|
||||
if ((BASH_VERSINFO[0] < 4)); then
|
||||
# CTRL-T - Paste the selected file path into the command line
|
||||
if [[ ${FZF_CTRL_T_COMMAND-x} != "" ]]; then
|
||||
bind -m emacs-standard '"\C-t": " \C-b\C-k \C-u`__fzf_select__`\e\C-e\er\C-a\C-y\C-h\C-e\e \C-y\ey\C-x\C-x\C-f\C-y\ey\C-_"'
|
||||
bind -m emacs-standard '"\C-t": " \C-b\C-k \C-u`__fzf_select__`\e\C-e\C-\e(\C-a\C-y\C-h\C-e\e \C-y\ey\C-x\C-x\C-f\C-y\ey\C-_"'
|
||||
bind -m vi-command '"\C-t": "\C-z\C-t\C-z"'
|
||||
bind -m vi-insert '"\C-t": "\C-z\C-t\C-z"'
|
||||
fi
|
||||
@@ -140,7 +161,7 @@ if ((BASH_VERSINFO[0] < 4)); then
|
||||
if [[ -n ${FZF_CTRL_R_COMMAND-} ]]; then
|
||||
echo "warning: FZF_CTRL_R_COMMAND is set to a custom command, but custom commands are not yet supported for CTRL-R" >&2
|
||||
fi
|
||||
bind -m emacs-standard '"\C-r": "\C-e \C-u\C-y\ey\C-u`__fzf_history__`\e\C-e\er"'
|
||||
bind -m emacs-standard '"\C-r": "\C-e \C-u\C-y\ey\C-u`__fzf_history__`\e\C-e\C-\e("'
|
||||
bind -m vi-command '"\C-r": "\C-z\C-r\C-z"'
|
||||
bind -m vi-insert '"\C-r": "\C-z\C-r\C-z"'
|
||||
fi
|
||||
@@ -165,7 +186,7 @@ fi
|
||||
|
||||
# ALT-C - cd into the selected directory
|
||||
if [[ ${FZF_ALT_C_COMMAND-x} != "" ]]; then
|
||||
bind -m emacs-standard '"\ec": " \C-b\C-k \C-u`__fzf_cd__`\e\C-e\er\C-m\C-y\C-h\e \C-y\ey\C-x\C-x\C-d\C-y\ey\C-_"'
|
||||
bind -m emacs-standard '"\ec": " \C-b\C-k \C-u`__fzf_cd__`\e\C-e\C-\e(\C-m\C-y\C-h\e \C-y\ey\C-x\C-x\C-d\C-y\ey\C-_"'
|
||||
bind -m vi-command '"\ec": "\C-z\ec\C-z"'
|
||||
bind -m vi-insert '"\ec": "\C-z\ec\C-z"'
|
||||
fi
|
||||
|
||||
+53
-70
@@ -4,7 +4,6 @@
|
||||
# / __/ / /_/ __/
|
||||
# /_/ /___/_/ key-bindings.fish
|
||||
#
|
||||
# - $FZF_TMUX_OPTS
|
||||
# - $FZF_CTRL_T_COMMAND
|
||||
# - $FZF_CTRL_T_OPTS
|
||||
# - $FZF_CTRL_R_COMMAND
|
||||
@@ -12,20 +11,17 @@
|
||||
# - $FZF_ALT_C_COMMAND
|
||||
# - $FZF_ALT_C_OPTS
|
||||
|
||||
|
||||
# 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
|
||||
|
||||
# Check fish version
|
||||
set -l fish_ver (string match -r '^(\d+).(\d+)' $version 2> /dev/null; or echo 0\n0\n0)
|
||||
if test \( "$fish_ver[2]" -lt 3 \) -o \( "$fish_ver[2]" -eq 3 -a "$fish_ver[3]" -lt 1 \)
|
||||
echo "This script requires fish version 3.1b1 or newer." >&2
|
||||
# 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
|
||||
# be used anywhere in the script, otherwise the source command will fail.
|
||||
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
|
||||
else if not type -q fzf
|
||||
else if not command -q fzf
|
||||
echo "fzf was not found in path." >&2
|
||||
return 1
|
||||
end
|
||||
@@ -37,7 +33,7 @@ function fzf_key_bindings
|
||||
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]
|
||||
$FZF_DEFAULT_OPTS $argv[2..]
|
||||
end
|
||||
|
||||
function __fzfcmd
|
||||
@@ -56,38 +52,25 @@ function fzf_key_bindings
|
||||
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]
|
||||
set -l -- match_regex '(?<fzf_query>[\\s\\S]*?(?=\\n?$)$)'
|
||||
set -l -- prefix_regex '^-[^\\s=]+=|^-(?!-)\\S'
|
||||
|
||||
# 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
|
||||
# Don't use option prefix if " -- " is preceded.
|
||||
string match -qv -- '* -- *' (string sub -l (commandline -Cp) -- (commandline -p))
|
||||
and set -- match_regex "(?<prefix>$prefix_regex)?$match_regex"
|
||||
|
||||
# Set $prefix and expanded $fzf_query with preserved trailing newlines.
|
||||
if test "$fish_major" -ge 4
|
||||
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)
|
||||
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$' '')
|
||||
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)' '')
|
||||
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 \)
|
||||
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 -- dir $fzf_query
|
||||
@@ -95,35 +78,22 @@ function fzf_key_bindings
|
||||
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
|
||||
string match -q -r -- '(?<fzf_query>^[\\s\\S]*?(?=\\n?$)$)' \
|
||||
(string replace -r -a -- '(?<=/)/|(?<!^)/+(?!\\n)$' '' $fzf_query | string collect -N)
|
||||
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 not string match -q -- '.' $dir; or string match -qr -- '^\\.(/|$)' $fzf_query
|
||||
# Strip $dir from $fzf_query - preserve trailing newlines.
|
||||
if test "$fish_major" -ge 4
|
||||
if string match -qr -- '^\\d\\d+|^[4-9]' $version
|
||||
# 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)
|
||||
string match -q -r -- '^'(string escape --style=regex -- $dir)'/?(?<fzf_query>[\\s\\S]*)' $fzf_query
|
||||
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$' '')
|
||||
string match -q -r -- '^/?(?<fzf_query>[\\s\\S]*?(?=\\n?$)$)' \
|
||||
(string replace -- "$dir" '' $fzf_query | string collect -N)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -140,13 +110,13 @@ function fzf_key_bindings
|
||||
|
||||
set -lx FZF_DEFAULT_OPTS (__fzf_defaults \
|
||||
"--reverse --walker=file,dir,follow,hidden --scheme=path" \
|
||||
"$FZF_CTRL_T_OPTS --multi --print0")
|
||||
"--multi $FZF_CTRL_T_OPTS --print0")
|
||||
|
||||
set -lx FZF_DEFAULT_COMMAND "$FZF_CTRL_T_COMMAND"
|
||||
set -lx FZF_DEFAULT_OPTS_FILE
|
||||
|
||||
set -l result (eval (__fzfcmd) --walker-root=$dir --query=$fzf_query | string split0)
|
||||
and commandline -rt -- (string join -- ' ' $prefix(string escape -- $result))' '
|
||||
and commandline -rt -- (string join -- ' ' $prefix(string escape -n -- $result))' '
|
||||
|
||||
commandline -f repaint
|
||||
end
|
||||
@@ -157,24 +127,34 @@ function fzf_key_bindings
|
||||
set -l -- total_lines (count $command_line)
|
||||
set -l -- fzf_query (string escape -- $command_line[$current_line])
|
||||
|
||||
set -lx FZF_DEFAULT_OPTS (__fzf_defaults '' \
|
||||
'--nth=2..,.. --scheme=history --multi --wrap-sign="\t↳ "' \
|
||||
'--bind=\'shift-delete:execute-silent(eval history delete --exact --case-sensitive -- (string escape -n -- {+} | string replace -r -a "^\d*\\\\\\t|(?<=\\\\\\n)\\\\\\t" ""))+reload(eval $FZF_DEFAULT_COMMAND)\'' \
|
||||
set -lx -- FZF_DEFAULT_OPTS (__fzf_defaults '' \
|
||||
'--with-nth=2.. --nth=2..,.. --scheme=history --multi --no-multi-line' \
|
||||
'--no-wrap --wrap-sign="\t\t\t↳ " --preview-wrap-sign="↳ " --freeze-left=1' \
|
||||
'--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(eval $FZF_DEFAULT_COMMAND)"' \
|
||||
"--bind=ctrl-r:toggle-sort,alt-r:toggle-raw --highlight-line $FZF_CTRL_R_OPTS" \
|
||||
'--accept-nth=2.. --read0 --print0 --with-shell='(status fish-path)\\ -c)
|
||||
'--accept-nth=3.. --delimiter="\t" --tabstop=4 --read0 --print0 --with-shell='(status fish-path)\\ -c)
|
||||
|
||||
# Add dynamic preview options if preview command isn't already set by user
|
||||
if string match -qvr -- '--preview[= ]' "$FZF_DEFAULT_OPTS"
|
||||
# Prepend the options to allow user overrides
|
||||
set -p -- FZF_DEFAULT_OPTS \
|
||||
'--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="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"'
|
||||
end
|
||||
|
||||
set -lx FZF_DEFAULT_OPTS_FILE
|
||||
set -lx FZF_DEFAULT_COMMAND
|
||||
|
||||
if type -q perl
|
||||
set -a FZF_DEFAULT_OPTS '--tac'
|
||||
set FZF_DEFAULT_COMMAND 'builtin history -z --reverse | command perl -0 -pe \'s/^/$.\t/g; s/\n/\n\t/gm\''
|
||||
set -lx -- FZF_DEFAULT_COMMAND 'builtin history -z'
|
||||
|
||||
# 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_OPTS '--ansi'
|
||||
set -a -- FZF_DEFAULT_COMMAND '--color=always --show-time=(set_color $fish_color_comment)"%F %a %T%t%s%t"(set_color $fish_color_normal)'
|
||||
else
|
||||
set FZF_DEFAULT_COMMAND \
|
||||
'set -l h (builtin history -z --reverse | string split0);' \
|
||||
'for i in (seq (count $h) -1 1);' \
|
||||
'string join0 -- $i\t(string replace -a -- \n \n\t $h[$i] | string collect);' \
|
||||
'end'
|
||||
set -a -- FZF_DEFAULT_COMMAND '--show-time="%F %a %T%t%s%t"'
|
||||
end
|
||||
|
||||
# Merge history from other sessions before searching
|
||||
@@ -182,11 +162,11 @@ function fzf_key_bindings
|
||||
|
||||
if set -l result (eval $FZF_DEFAULT_COMMAND \| (__fzfcmd) --query=$fzf_query | string split0)
|
||||
if test "$total_lines" -eq 1
|
||||
commandline -- (string replace -a -- \n\t \n $result)
|
||||
commandline -- $result
|
||||
else
|
||||
set -l a (math $current_line - 1)
|
||||
set -l b (math $current_line + 1)
|
||||
commandline -- $command_line[1..$a] (string replace -a -- \n\t \n $result)
|
||||
commandline -- $command_line[1..$a] $result
|
||||
commandline -a -- '' $command_line[$b..-1]
|
||||
end
|
||||
end
|
||||
@@ -234,3 +214,6 @@ function fzf_key_bindings
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
# Run setup
|
||||
fzf_key_bindings
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+42
-10
@@ -4,7 +4,6 @@
|
||||
# / __/ / /_/ __/
|
||||
# /_/ /___/_/ key-bindings.zsh
|
||||
#
|
||||
# - $FZF_TMUX_OPTS
|
||||
# - $FZF_CTRL_T_COMMAND
|
||||
# - $FZF_CTRL_T_OPTS
|
||||
# - $FZF_CTRL_R_COMMAND
|
||||
@@ -45,9 +44,9 @@ if [[ -o interactive ]]; then
|
||||
# the changes. See code comments in "common.sh" for the implementation details.
|
||||
|
||||
__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
|
||||
printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
|
||||
builtin printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
|
||||
}
|
||||
|
||||
__fzf_exec_awk() {
|
||||
@@ -111,8 +110,14 @@ fzf-cd-widget() {
|
||||
zle redisplay
|
||||
return 0
|
||||
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 >/dev/null -- "${dir}" && echo "${PWD}" || echo "${dir}")
|
||||
zle push-line # Clear buffer. Auto-restored on next prompt.
|
||||
BUFFER="builtin cd -- ${(q)dir:a}"
|
||||
BUFFER="builtin cd -- ${(q)dir}"
|
||||
zle accept-line
|
||||
local ret=$?
|
||||
unset dir # ensure this doesn't end up appearing in prompt expansion
|
||||
@@ -128,25 +133,52 @@ fi
|
||||
|
||||
# CTRL-R - Paste the selected command from history into the command line
|
||||
fzf-history-widget() {
|
||||
local selected
|
||||
setopt localoptions noglobsubst noposixbuiltins pipefail no_aliases noglob nobash_rematch 2> /dev/null
|
||||
local selected extracted_with_perl=0
|
||||
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
|
||||
# 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.
|
||||
if zmodload -F zsh/parameter p:{commands,history} 2>/dev/null && (( ${+commands[perl]} )); then
|
||||
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; }' |
|
||||
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-} --query=${(qqq)LBUFFER} +m --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))"
|
||||
extracted_with_perl=1
|
||||
else
|
||||
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 ${FZF_CTRL_R_OPTS-} --query=${(qqq)LBUFFER} +m") \
|
||||
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_FILE='' $(__fzfcmd))"
|
||||
fi
|
||||
local ret=$?
|
||||
local -a cmds
|
||||
# Avoid leaking auto assigned values when using backreferences '(#b)'
|
||||
local -a mbegin mend match
|
||||
if [ -n "$selected" ]; then
|
||||
if [[ $(__fzf_exec_awk '{print $1; exit}' <<< "$selected") =~ ^[1-9][0-9]* ]]; then
|
||||
zle vi-fetch-history -n $MATCH
|
||||
# Heuristic to check if the selected value is from history or a custom query
|
||||
if ((( extracted_with_perl )) && [[ $selected == <->$'\t'* ]]) ||
|
||||
((( ! extracted_with_perl )) && [[ $selected == [[:blank:]]#<->( |\* )* ]]); then
|
||||
# Split at newlines
|
||||
for line in ${(ps:\n:)selected}; do
|
||||
if (( extracted_with_perl )); then
|
||||
if [[ $line == (#b)(<->)(#B)$'\t'* ]]; then
|
||||
(( ${+history[${match[1]}]} )) && cmds+=("${history[${match[1]}]}")
|
||||
fi
|
||||
elif [[ $line == [[:blank:]]#(#b)(<->)(#B)( |\* )* ]]; then
|
||||
# Avoid $history array: lags behind 'fc' on foreign commands (*)
|
||||
# https://zsh.org/mla/users/2024/msg00692.html
|
||||
# Push BUFFER onto stack; fetch and save history entry from BUFFER; restore
|
||||
zle .push-line
|
||||
zle vi-fetch-history -n ${match[1]}
|
||||
(( ${#BUFFER} )) && cmds+=("${BUFFER}")
|
||||
BUFFER=""
|
||||
zle .get-line
|
||||
fi
|
||||
done
|
||||
if (( ${#cmds[@]} )); then
|
||||
# Join by newline after stripping trailing newlines from each command
|
||||
BUFFER="${(pj:\n:)${(@)cmds%%$'\n'#}}"
|
||||
CURSOR=${#BUFFER}
|
||||
fi
|
||||
else # selected is a custom query, not from history
|
||||
LBUFFER="$selected"
|
||||
fi
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2013-2025 Junegunn Choi
|
||||
Copyright (c) 2013-2026 Junegunn Choi
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
+158
-150
@@ -30,159 +30,167 @@ func _() {
|
||||
_ = x[actChangeBorderLabel-19]
|
||||
_ = x[actChangeGhost-20]
|
||||
_ = x[actChangeHeader-21]
|
||||
_ = x[actChangeFooter-22]
|
||||
_ = x[actChangeHeaderLabel-23]
|
||||
_ = x[actChangeFooterLabel-24]
|
||||
_ = x[actChangeInputLabel-25]
|
||||
_ = x[actChangeListLabel-26]
|
||||
_ = x[actChangeMulti-27]
|
||||
_ = x[actChangeNth-28]
|
||||
_ = x[actChangePointer-29]
|
||||
_ = x[actChangePreview-30]
|
||||
_ = x[actChangePreviewLabel-31]
|
||||
_ = x[actChangePreviewWindow-32]
|
||||
_ = x[actChangePrompt-33]
|
||||
_ = x[actChangeQuery-34]
|
||||
_ = x[actClearScreen-35]
|
||||
_ = x[actClearQuery-36]
|
||||
_ = x[actClearSelection-37]
|
||||
_ = x[actClose-38]
|
||||
_ = x[actDeleteChar-39]
|
||||
_ = x[actDeleteCharEof-40]
|
||||
_ = x[actEndOfLine-41]
|
||||
_ = x[actFatal-42]
|
||||
_ = x[actForwardChar-43]
|
||||
_ = x[actForwardWord-44]
|
||||
_ = x[actForwardSubWord-45]
|
||||
_ = x[actKillLine-46]
|
||||
_ = x[actKillWord-47]
|
||||
_ = x[actKillSubWord-48]
|
||||
_ = x[actUnixLineDiscard-49]
|
||||
_ = x[actUnixWordRubout-50]
|
||||
_ = x[actYank-51]
|
||||
_ = x[actBackwardKillWord-52]
|
||||
_ = x[actBackwardKillSubWord-53]
|
||||
_ = x[actSelectAll-54]
|
||||
_ = x[actDeselectAll-55]
|
||||
_ = x[actToggle-56]
|
||||
_ = x[actToggleSearch-57]
|
||||
_ = x[actToggleAll-58]
|
||||
_ = x[actToggleDown-59]
|
||||
_ = x[actToggleUp-60]
|
||||
_ = x[actToggleIn-61]
|
||||
_ = x[actToggleOut-62]
|
||||
_ = x[actToggleTrack-63]
|
||||
_ = x[actToggleTrackCurrent-64]
|
||||
_ = x[actToggleHeader-65]
|
||||
_ = x[actToggleWrap-66]
|
||||
_ = x[actToggleMultiLine-67]
|
||||
_ = x[actToggleHscroll-68]
|
||||
_ = x[actToggleRaw-69]
|
||||
_ = x[actEnableRaw-70]
|
||||
_ = x[actDisableRaw-71]
|
||||
_ = x[actTrackCurrent-72]
|
||||
_ = x[actToggleInput-73]
|
||||
_ = x[actHideInput-74]
|
||||
_ = x[actShowInput-75]
|
||||
_ = x[actUntrackCurrent-76]
|
||||
_ = x[actDown-77]
|
||||
_ = x[actDownMatch-78]
|
||||
_ = x[actUp-79]
|
||||
_ = x[actUpMatch-80]
|
||||
_ = x[actPageUp-81]
|
||||
_ = x[actPageDown-82]
|
||||
_ = x[actPosition-83]
|
||||
_ = x[actHalfPageUp-84]
|
||||
_ = x[actHalfPageDown-85]
|
||||
_ = x[actOffsetUp-86]
|
||||
_ = x[actOffsetDown-87]
|
||||
_ = x[actOffsetMiddle-88]
|
||||
_ = x[actJump-89]
|
||||
_ = x[actJumpAccept-90]
|
||||
_ = x[actPrintQuery-91]
|
||||
_ = x[actRefreshPreview-92]
|
||||
_ = x[actReplaceQuery-93]
|
||||
_ = x[actToggleSort-94]
|
||||
_ = x[actShowPreview-95]
|
||||
_ = x[actHidePreview-96]
|
||||
_ = x[actTogglePreview-97]
|
||||
_ = x[actTogglePreviewWrap-98]
|
||||
_ = x[actTransform-99]
|
||||
_ = x[actTransformBorderLabel-100]
|
||||
_ = x[actTransformGhost-101]
|
||||
_ = x[actTransformHeader-102]
|
||||
_ = x[actTransformFooter-103]
|
||||
_ = x[actTransformHeaderLabel-104]
|
||||
_ = x[actTransformFooterLabel-105]
|
||||
_ = x[actTransformInputLabel-106]
|
||||
_ = x[actTransformListLabel-107]
|
||||
_ = x[actTransformNth-108]
|
||||
_ = x[actTransformPointer-109]
|
||||
_ = x[actTransformPreviewLabel-110]
|
||||
_ = x[actTransformPrompt-111]
|
||||
_ = x[actTransformQuery-112]
|
||||
_ = x[actTransformSearch-113]
|
||||
_ = x[actTrigger-114]
|
||||
_ = x[actBgTransform-115]
|
||||
_ = x[actBgTransformBorderLabel-116]
|
||||
_ = x[actBgTransformGhost-117]
|
||||
_ = x[actBgTransformHeader-118]
|
||||
_ = x[actBgTransformFooter-119]
|
||||
_ = x[actBgTransformHeaderLabel-120]
|
||||
_ = x[actBgTransformFooterLabel-121]
|
||||
_ = x[actBgTransformInputLabel-122]
|
||||
_ = x[actBgTransformListLabel-123]
|
||||
_ = x[actBgTransformNth-124]
|
||||
_ = x[actBgTransformPointer-125]
|
||||
_ = x[actBgTransformPreviewLabel-126]
|
||||
_ = x[actBgTransformPrompt-127]
|
||||
_ = x[actBgTransformQuery-128]
|
||||
_ = x[actBgTransformSearch-129]
|
||||
_ = x[actBgCancel-130]
|
||||
_ = x[actSearch-131]
|
||||
_ = x[actPreview-132]
|
||||
_ = x[actPreviewTop-133]
|
||||
_ = x[actPreviewBottom-134]
|
||||
_ = x[actPreviewUp-135]
|
||||
_ = x[actPreviewDown-136]
|
||||
_ = x[actPreviewPageUp-137]
|
||||
_ = x[actPreviewPageDown-138]
|
||||
_ = x[actPreviewHalfPageUp-139]
|
||||
_ = x[actPreviewHalfPageDown-140]
|
||||
_ = x[actPrevHistory-141]
|
||||
_ = x[actPrevSelected-142]
|
||||
_ = x[actPrint-143]
|
||||
_ = x[actPut-144]
|
||||
_ = x[actNextHistory-145]
|
||||
_ = x[actNextSelected-146]
|
||||
_ = x[actExecute-147]
|
||||
_ = x[actExecuteSilent-148]
|
||||
_ = x[actExecuteMulti-149]
|
||||
_ = x[actSigStop-150]
|
||||
_ = x[actBest-151]
|
||||
_ = x[actFirst-152]
|
||||
_ = x[actLast-153]
|
||||
_ = x[actReload-154]
|
||||
_ = x[actReloadSync-155]
|
||||
_ = x[actDisableSearch-156]
|
||||
_ = x[actEnableSearch-157]
|
||||
_ = x[actSelect-158]
|
||||
_ = x[actDeselect-159]
|
||||
_ = x[actUnbind-160]
|
||||
_ = x[actRebind-161]
|
||||
_ = x[actToggleBind-162]
|
||||
_ = x[actBecome-163]
|
||||
_ = x[actShowHeader-164]
|
||||
_ = x[actHideHeader-165]
|
||||
_ = x[actBell-166]
|
||||
_ = x[actExclude-167]
|
||||
_ = x[actExcludeMulti-168]
|
||||
_ = x[actAsync-169]
|
||||
_ = x[actChangeHeaderLines-22]
|
||||
_ = x[actChangeFooter-23]
|
||||
_ = x[actChangeHeaderLabel-24]
|
||||
_ = x[actChangeFooterLabel-25]
|
||||
_ = x[actChangeInputLabel-26]
|
||||
_ = x[actChangeListLabel-27]
|
||||
_ = x[actChangeMulti-28]
|
||||
_ = x[actChangeNth-29]
|
||||
_ = x[actChangeWithNth-30]
|
||||
_ = x[actChangePointer-31]
|
||||
_ = x[actChangePreview-32]
|
||||
_ = x[actChangePreviewLabel-33]
|
||||
_ = x[actChangePreviewWindow-34]
|
||||
_ = x[actChangePrompt-35]
|
||||
_ = x[actChangeQuery-36]
|
||||
_ = x[actClearScreen-37]
|
||||
_ = x[actClearQuery-38]
|
||||
_ = x[actClearSelection-39]
|
||||
_ = x[actClose-40]
|
||||
_ = x[actDeleteChar-41]
|
||||
_ = x[actDeleteCharEof-42]
|
||||
_ = x[actEndOfLine-43]
|
||||
_ = x[actFatal-44]
|
||||
_ = x[actForwardChar-45]
|
||||
_ = x[actForwardWord-46]
|
||||
_ = x[actForwardSubWord-47]
|
||||
_ = x[actKillLine-48]
|
||||
_ = x[actKillWord-49]
|
||||
_ = x[actKillSubWord-50]
|
||||
_ = x[actUnixLineDiscard-51]
|
||||
_ = x[actUnixWordRubout-52]
|
||||
_ = x[actYank-53]
|
||||
_ = x[actBackwardKillWord-54]
|
||||
_ = x[actBackwardKillSubWord-55]
|
||||
_ = x[actSelectAll-56]
|
||||
_ = x[actDeselectAll-57]
|
||||
_ = x[actToggle-58]
|
||||
_ = x[actToggleSearch-59]
|
||||
_ = x[actToggleAll-60]
|
||||
_ = x[actToggleDown-61]
|
||||
_ = x[actToggleUp-62]
|
||||
_ = x[actToggleIn-63]
|
||||
_ = x[actToggleOut-64]
|
||||
_ = x[actToggleTrack-65]
|
||||
_ = x[actToggleTrackCurrent-66]
|
||||
_ = x[actToggleHeader-67]
|
||||
_ = x[actToggleWrap-68]
|
||||
_ = x[actToggleWrapWord-69]
|
||||
_ = x[actToggleMultiLine-70]
|
||||
_ = x[actToggleHscroll-71]
|
||||
_ = x[actToggleRaw-72]
|
||||
_ = x[actEnableRaw-73]
|
||||
_ = x[actDisableRaw-74]
|
||||
_ = x[actTrackCurrent-75]
|
||||
_ = x[actToggleInput-76]
|
||||
_ = x[actHideInput-77]
|
||||
_ = x[actShowInput-78]
|
||||
_ = x[actUntrackCurrent-79]
|
||||
_ = x[actDown-80]
|
||||
_ = x[actDownMatch-81]
|
||||
_ = x[actUp-82]
|
||||
_ = x[actUpMatch-83]
|
||||
_ = x[actPageUp-84]
|
||||
_ = x[actPageDown-85]
|
||||
_ = x[actPosition-86]
|
||||
_ = x[actHalfPageUp-87]
|
||||
_ = x[actHalfPageDown-88]
|
||||
_ = x[actOffsetUp-89]
|
||||
_ = x[actOffsetDown-90]
|
||||
_ = x[actOffsetMiddle-91]
|
||||
_ = x[actJump-92]
|
||||
_ = x[actJumpAccept-93]
|
||||
_ = x[actPrintQuery-94]
|
||||
_ = x[actRefreshPreview-95]
|
||||
_ = x[actReplaceQuery-96]
|
||||
_ = x[actToggleSort-97]
|
||||
_ = x[actShowPreview-98]
|
||||
_ = x[actHidePreview-99]
|
||||
_ = x[actTogglePreview-100]
|
||||
_ = x[actTogglePreviewWrap-101]
|
||||
_ = x[actTogglePreviewWrapWord-102]
|
||||
_ = x[actTransform-103]
|
||||
_ = x[actTransformBorderLabel-104]
|
||||
_ = x[actTransformGhost-105]
|
||||
_ = x[actTransformHeader-106]
|
||||
_ = x[actTransformHeaderLines-107]
|
||||
_ = x[actTransformFooter-108]
|
||||
_ = x[actTransformHeaderLabel-109]
|
||||
_ = x[actTransformFooterLabel-110]
|
||||
_ = x[actTransformInputLabel-111]
|
||||
_ = x[actTransformListLabel-112]
|
||||
_ = x[actTransformNth-113]
|
||||
_ = x[actTransformWithNth-114]
|
||||
_ = x[actTransformPointer-115]
|
||||
_ = x[actTransformPreviewLabel-116]
|
||||
_ = x[actTransformPrompt-117]
|
||||
_ = x[actTransformQuery-118]
|
||||
_ = x[actTransformSearch-119]
|
||||
_ = x[actTrigger-120]
|
||||
_ = x[actBgTransform-121]
|
||||
_ = x[actBgTransformBorderLabel-122]
|
||||
_ = x[actBgTransformGhost-123]
|
||||
_ = x[actBgTransformHeader-124]
|
||||
_ = x[actBgTransformHeaderLines-125]
|
||||
_ = x[actBgTransformFooter-126]
|
||||
_ = x[actBgTransformHeaderLabel-127]
|
||||
_ = x[actBgTransformFooterLabel-128]
|
||||
_ = x[actBgTransformInputLabel-129]
|
||||
_ = x[actBgTransformListLabel-130]
|
||||
_ = x[actBgTransformNth-131]
|
||||
_ = x[actBgTransformWithNth-132]
|
||||
_ = x[actBgTransformPointer-133]
|
||||
_ = x[actBgTransformPreviewLabel-134]
|
||||
_ = x[actBgTransformPrompt-135]
|
||||
_ = x[actBgTransformQuery-136]
|
||||
_ = x[actBgTransformSearch-137]
|
||||
_ = x[actBgCancel-138]
|
||||
_ = x[actSearch-139]
|
||||
_ = x[actPreview-140]
|
||||
_ = x[actPreviewTop-141]
|
||||
_ = x[actPreviewBottom-142]
|
||||
_ = x[actPreviewUp-143]
|
||||
_ = x[actPreviewDown-144]
|
||||
_ = x[actPreviewPageUp-145]
|
||||
_ = x[actPreviewPageDown-146]
|
||||
_ = x[actPreviewHalfPageUp-147]
|
||||
_ = x[actPreviewHalfPageDown-148]
|
||||
_ = x[actPrevHistory-149]
|
||||
_ = x[actPrevSelected-150]
|
||||
_ = x[actPrint-151]
|
||||
_ = x[actPut-152]
|
||||
_ = x[actNextHistory-153]
|
||||
_ = x[actNextSelected-154]
|
||||
_ = x[actExecute-155]
|
||||
_ = x[actExecuteSilent-156]
|
||||
_ = x[actExecuteMulti-157]
|
||||
_ = x[actSigStop-158]
|
||||
_ = x[actBest-159]
|
||||
_ = x[actFirst-160]
|
||||
_ = x[actLast-161]
|
||||
_ = x[actReload-162]
|
||||
_ = x[actReloadSync-163]
|
||||
_ = x[actDisableSearch-164]
|
||||
_ = x[actEnableSearch-165]
|
||||
_ = x[actSelect-166]
|
||||
_ = x[actDeselect-167]
|
||||
_ = x[actUnbind-168]
|
||||
_ = x[actRebind-169]
|
||||
_ = x[actToggleBind-170]
|
||||
_ = x[actBecome-171]
|
||||
_ = x[actShowHeader-172]
|
||||
_ = x[actHideHeader-173]
|
||||
_ = x[actBell-174]
|
||||
_ = x[actExclude-175]
|
||||
_ = x[actExcludeMulti-176]
|
||||
_ = x[actAsync-177]
|
||||
}
|
||||
|
||||
const _actionType_name = "actIgnoreactStartactClickactInvalidactBracketedPasteBeginactBracketedPasteEndactCharactMouseactBeginningOfLineactAbortactAcceptactAcceptNonEmptyactAcceptOrPrintQueryactBackwardCharactBackwardDeleteCharactBackwardDeleteCharEofactBackwardWordactBackwardSubWordactCancelactChangeBorderLabelactChangeGhostactChangeHeaderactChangeFooteractChangeHeaderLabelactChangeFooterLabelactChangeInputLabelactChangeListLabelactChangeMultiactChangeNthactChangePointeractChangePreviewactChangePreviewLabelactChangePreviewWindowactChangePromptactChangeQueryactClearScreenactClearQueryactClearSelectionactCloseactDeleteCharactDeleteCharEofactEndOfLineactFatalactForwardCharactForwardWordactForwardSubWordactKillLineactKillWordactKillSubWordactUnixLineDiscardactUnixWordRuboutactYankactBackwardKillWordactBackwardKillSubWordactSelectAllactDeselectAllactToggleactToggleSearchactToggleAllactToggleDownactToggleUpactToggleInactToggleOutactToggleTrackactToggleTrackCurrentactToggleHeaderactToggleWrapactToggleMultiLineactToggleHscrollactToggleRawactEnableRawactDisableRawactTrackCurrentactToggleInputactHideInputactShowInputactUntrackCurrentactDownactDownMatchactUpactUpMatchactPageUpactPageDownactPositionactHalfPageUpactHalfPageDownactOffsetUpactOffsetDownactOffsetMiddleactJumpactJumpAcceptactPrintQueryactRefreshPreviewactReplaceQueryactToggleSortactShowPreviewactHidePreviewactTogglePreviewactTogglePreviewWrapactTransformactTransformBorderLabelactTransformGhostactTransformHeaderactTransformFooteractTransformHeaderLabelactTransformFooterLabelactTransformInputLabelactTransformListLabelactTransformNthactTransformPointeractTransformPreviewLabelactTransformPromptactTransformQueryactTransformSearchactTriggeractBgTransformactBgTransformBorderLabelactBgTransformGhostactBgTransformHeaderactBgTransformFooteractBgTransformHeaderLabelactBgTransformFooterLabelactBgTransformInputLabelactBgTransformListLabelactBgTransformNthactBgTransformPointeractBgTransformPreviewLabelactBgTransformPromptactBgTransformQueryactBgTransformSearchactBgCancelactSearchactPreviewactPreviewTopactPreviewBottomactPreviewUpactPreviewDownactPreviewPageUpactPreviewPageDownactPreviewHalfPageUpactPreviewHalfPageDownactPrevHistoryactPrevSelectedactPrintactPutactNextHistoryactNextSelectedactExecuteactExecuteSilentactExecuteMultiactSigStopactBestactFirstactLastactReloadactReloadSyncactDisableSearchactEnableSearchactSelectactDeselectactUnbindactRebindactToggleBindactBecomeactShowHeaderactHideHeaderactBellactExcludeactExcludeMultiactAsync"
|
||||
const _actionType_name = "actIgnoreactStartactClickactInvalidactBracketedPasteBeginactBracketedPasteEndactCharactMouseactBeginningOfLineactAbortactAcceptactAcceptNonEmptyactAcceptOrPrintQueryactBackwardCharactBackwardDeleteCharactBackwardDeleteCharEofactBackwardWordactBackwardSubWordactCancelactChangeBorderLabelactChangeGhostactChangeHeaderactChangeHeaderLinesactChangeFooteractChangeHeaderLabelactChangeFooterLabelactChangeInputLabelactChangeListLabelactChangeMultiactChangeNthactChangeWithNthactChangePointeractChangePreviewactChangePreviewLabelactChangePreviewWindowactChangePromptactChangeQueryactClearScreenactClearQueryactClearSelectionactCloseactDeleteCharactDeleteCharEofactEndOfLineactFatalactForwardCharactForwardWordactForwardSubWordactKillLineactKillWordactKillSubWordactUnixLineDiscardactUnixWordRuboutactYankactBackwardKillWordactBackwardKillSubWordactSelectAllactDeselectAllactToggleactToggleSearchactToggleAllactToggleDownactToggleUpactToggleInactToggleOutactToggleTrackactToggleTrackCurrentactToggleHeaderactToggleWrapactToggleWrapWordactToggleMultiLineactToggleHscrollactToggleRawactEnableRawactDisableRawactTrackCurrentactToggleInputactHideInputactShowInputactUntrackCurrentactDownactDownMatchactUpactUpMatchactPageUpactPageDownactPositionactHalfPageUpactHalfPageDownactOffsetUpactOffsetDownactOffsetMiddleactJumpactJumpAcceptactPrintQueryactRefreshPreviewactReplaceQueryactToggleSortactShowPreviewactHidePreviewactTogglePreviewactTogglePreviewWrapactTogglePreviewWrapWordactTransformactTransformBorderLabelactTransformGhostactTransformHeaderactTransformHeaderLinesactTransformFooteractTransformHeaderLabelactTransformFooterLabelactTransformInputLabelactTransformListLabelactTransformNthactTransformWithNthactTransformPointeractTransformPreviewLabelactTransformPromptactTransformQueryactTransformSearchactTriggeractBgTransformactBgTransformBorderLabelactBgTransformGhostactBgTransformHeaderactBgTransformHeaderLinesactBgTransformFooteractBgTransformHeaderLabelactBgTransformFooterLabelactBgTransformInputLabelactBgTransformListLabelactBgTransformNthactBgTransformWithNthactBgTransformPointeractBgTransformPreviewLabelactBgTransformPromptactBgTransformQueryactBgTransformSearchactBgCancelactSearchactPreviewactPreviewTopactPreviewBottomactPreviewUpactPreviewDownactPreviewPageUpactPreviewPageDownactPreviewHalfPageUpactPreviewHalfPageDownactPrevHistoryactPrevSelectedactPrintactPutactNextHistoryactNextSelectedactExecuteactExecuteSilentactExecuteMultiactSigStopactBestactFirstactLastactReloadactReloadSyncactDisableSearchactEnableSearchactSelectactDeselectactUnbindactRebindactToggleBindactBecomeactShowHeaderactHideHeaderactBellactExcludeactExcludeMultiactAsync"
|
||||
|
||||
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, 993, 1009, 1021, 1033, 1046, 1061, 1075, 1087, 1099, 1116, 1123, 1135, 1140, 1150, 1159, 1170, 1181, 1194, 1209, 1220, 1233, 1248, 1255, 1268, 1281, 1298, 1313, 1326, 1340, 1354, 1370, 1390, 1402, 1425, 1442, 1460, 1478, 1501, 1524, 1546, 1567, 1582, 1601, 1625, 1643, 1660, 1678, 1688, 1702, 1727, 1746, 1766, 1786, 1811, 1836, 1860, 1883, 1900, 1921, 1947, 1967, 1986, 2006, 2017, 2026, 2036, 2049, 2065, 2077, 2091, 2107, 2125, 2145, 2167, 2181, 2196, 2204, 2210, 2224, 2239, 2249, 2265, 2280, 2290, 2297, 2305, 2312, 2321, 2334, 2350, 2365, 2374, 2385, 2394, 2403, 2416, 2425, 2438, 2451, 2458, 2468, 2483, 2491}
|
||||
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}
|
||||
|
||||
func (i actionType) String() string {
|
||||
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.
|
||||
+26
-28
@@ -266,7 +266,7 @@ func charClassOf(char rune) charClass {
|
||||
}
|
||||
|
||||
func bonusFor(prevClass charClass, class charClass) int16 {
|
||||
if class > charNonWord {
|
||||
if class >= charNonWord {
|
||||
switch prevClass {
|
||||
case charWhite:
|
||||
// Word boundary after whitespace
|
||||
@@ -321,22 +321,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 {
|
||||
byteArray := input.Bytes()[from:]
|
||||
idx := bytes.IndexByte(byteArray, b)
|
||||
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.
|
||||
// For case-insensitive search of a letter, search for both cases in one pass
|
||||
if !caseSensitive && b >= 'a' && b <= 'z' {
|
||||
if idx > 0 {
|
||||
byteArray = byteArray[:idx]
|
||||
}
|
||||
uidx := bytes.IndexByte(byteArray, b-32)
|
||||
if uidx >= 0 {
|
||||
idx = uidx
|
||||
idx := IndexByteTwo(byteArray, b, b-32)
|
||||
if idx < 0 {
|
||||
return -1
|
||||
}
|
||||
return from + idx
|
||||
}
|
||||
idx := bytes.IndexByte(byteArray, b)
|
||||
if idx < 0 {
|
||||
return -1
|
||||
}
|
||||
@@ -380,14 +373,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
|
||||
bu := b
|
||||
if !caseSensitive && b >= 'a' && b <= 'z' {
|
||||
bu = b - 32
|
||||
}
|
||||
scope := input.Bytes()[lastIdx:]
|
||||
for offset := len(scope) - 1; offset > 0; offset-- {
|
||||
if scope[offset] == b || scope[offset] == bu {
|
||||
return firstIdx, lastIdx + offset + 1
|
||||
if len(scope) > 1 {
|
||||
tail := scope[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
|
||||
@@ -445,7 +441,9 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
|
||||
|
||||
// Since O(nm) algorithm can be prohibitively expensive for large input,
|
||||
// we fall back to the greedy algorithm.
|
||||
if slab != nil && N*M > cap(slab.I16) {
|
||||
// Also, we should not allow a very long pattern to avoid 16-bit integer
|
||||
// overflow in the score matrix. 1000 is a safe limit.
|
||||
if slab != nil && int64(N)*int64(M) > int64(cap(slab.I16)) || M > 1000 {
|
||||
return FuzzyMatchV1(caseSensitive, normalize, forward, input, pattern, withPos, slab)
|
||||
}
|
||||
|
||||
@@ -501,7 +499,7 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
|
||||
if pidx < M {
|
||||
F[pidx] = int32(off)
|
||||
pidx++
|
||||
pchar = pattern[util.Min(pidx, M-1)]
|
||||
pchar = pattern[min(pidx, M-1)]
|
||||
}
|
||||
lastIdx = off
|
||||
}
|
||||
@@ -519,9 +517,9 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
|
||||
inGap = false
|
||||
} else {
|
||||
if inGap {
|
||||
H0[off] = util.Max16(prevH0+scoreGapExtension, 0)
|
||||
H0[off] = max(prevH0+scoreGapExtension, 0)
|
||||
} else {
|
||||
H0[off] = util.Max16(prevH0+scoreGapStart, 0)
|
||||
H0[off] = max(prevH0+scoreGapStart, 0)
|
||||
}
|
||||
C0[off] = 0
|
||||
inGap = true
|
||||
@@ -587,7 +585,7 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
|
||||
if b >= bonusBoundary && b > fb {
|
||||
consecutive = 1
|
||||
} else {
|
||||
b = util.Max16(b, util.Max16(bonusConsecutive, fb))
|
||||
b = max(b, bonusConsecutive, fb)
|
||||
}
|
||||
}
|
||||
if s1+b < s2 {
|
||||
@@ -600,7 +598,7 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
|
||||
Csub[off] = consecutive
|
||||
|
||||
inGap = s1 < s2
|
||||
score := util.Max16(util.Max16(s1, s2), 0)
|
||||
score := max(s1, s2, 0)
|
||||
if pidx == M-1 && (forward && score > maxScore || !forward && score >= maxScore) {
|
||||
maxScore, maxScorePos = score, col
|
||||
}
|
||||
@@ -684,7 +682,7 @@ func calculateScore(caseSensitive bool, normalize bool, text *util.Chars, patter
|
||||
if bonus >= bonusBoundary && bonus > firstBonus {
|
||||
firstBonus = bonus
|
||||
}
|
||||
bonus = util.Max16(util.Max16(bonus, firstBonus), bonusConsecutive)
|
||||
bonus = max(bonus, firstBonus, bonusConsecutive)
|
||||
}
|
||||
if pidx == 0 {
|
||||
score += int(bonus * bonusFirstCharMultiplier)
|
||||
|
||||
+10
-1
@@ -57,6 +57,15 @@ func TestFuzzyMatch(t *testing.T) {
|
||||
scoreMatch*4+int(bonusBoundaryDelimiter)*bonusFirstCharMultiplier+int(bonusBoundaryDelimiter)*3)
|
||||
assertMatch(t, fn, false, forward, "/.oh-my-zsh/cache", "zshc", 8, 13,
|
||||
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,
|
||||
scoreMatch*5+bonusConsecutive*3+scoreGapStart+scoreGapExtension)
|
||||
assertMatch(t, fn, false, forward, "abc123 456", "12356", 3, 10,
|
||||
@@ -86,7 +95,7 @@ func TestFuzzyMatch(t *testing.T) {
|
||||
scoreGapStart*2+scoreGapExtension*2)
|
||||
assertMatch(t, fn, true, forward, "FooBar Baz", "FooB", 0, 4,
|
||||
scoreMatch*4+int(bonusBoundaryWhite)*bonusFirstCharMultiplier+int(bonusBoundaryWhite)*2+
|
||||
util.Max(bonusCamel123, int(bonusBoundaryWhite)))
|
||||
max(bonusCamel123, int(bonusBoundaryWhite)))
|
||||
|
||||
// Consecutive bonus updated
|
||||
assertMatch(t, fn, true, forward, "foo-bar", "o-ba", 2, 6,
|
||||
|
||||
@@ -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) }
|
||||
+98
-33
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/junegunn/fzf/src/algo"
|
||||
"github.com/junegunn/fzf/src/tui"
|
||||
)
|
||||
|
||||
@@ -22,20 +23,21 @@ type url struct {
|
||||
type ansiState struct {
|
||||
fg tui.Color
|
||||
bg tui.Color
|
||||
ul tui.Color
|
||||
attr tui.Attr
|
||||
lbg tui.Color
|
||||
url *url
|
||||
}
|
||||
|
||||
func (s *ansiState) colored() bool {
|
||||
return s.fg != -1 || s.bg != -1 || s.attr > 0 || s.lbg >= 0 || s.url != nil
|
||||
return s.fg != -1 || s.bg != -1 || s.ul != -1 || s.attr > 0 || s.lbg >= 0 || s.url != nil
|
||||
}
|
||||
|
||||
func (s *ansiState) equals(t *ansiState) bool {
|
||||
if t == nil {
|
||||
return !s.colored()
|
||||
}
|
||||
return s.fg == t.fg && s.bg == t.bg && s.attr == t.attr && s.lbg == t.lbg && s.url == t.url
|
||||
return s.fg == t.fg && s.bg == t.bg && s.ul == t.ul && s.attr == t.attr && s.lbg == t.lbg && s.url == t.url
|
||||
}
|
||||
|
||||
func (s *ansiState) ToString() string {
|
||||
@@ -54,7 +56,18 @@ func (s *ansiState) ToString() string {
|
||||
ret += "3;"
|
||||
}
|
||||
if s.attr&tui.Underline > 0 {
|
||||
ret += "4;"
|
||||
switch s.attr.UnderlineStyle() {
|
||||
case tui.UlStyleDouble:
|
||||
ret += "4:2;"
|
||||
case tui.UlStyleCurly:
|
||||
ret += "4:3;"
|
||||
case tui.UlStyleDotted:
|
||||
ret += "4:4;"
|
||||
case tui.UlStyleDashed:
|
||||
ret += "4:5;"
|
||||
default:
|
||||
ret += "4;"
|
||||
}
|
||||
}
|
||||
if s.attr&tui.Blink > 0 {
|
||||
ret += "5;"
|
||||
@@ -66,6 +79,9 @@ func (s *ansiState) ToString() string {
|
||||
ret += "9;"
|
||||
}
|
||||
ret += toAnsiString(s.fg, 30) + toAnsiString(s.bg, 40)
|
||||
if s.ul != -1 {
|
||||
ret += toAnsiStringUl(s.ul)
|
||||
}
|
||||
|
||||
ret = "\x1b[" + strings.TrimSuffix(ret, ";") + "m"
|
||||
if s.url != nil {
|
||||
@@ -74,6 +90,20 @@ func (s *ansiState) ToString() string {
|
||||
return ret
|
||||
}
|
||||
|
||||
func toAnsiStringUl(color tui.Color) string {
|
||||
col := int(color)
|
||||
if col < 0 {
|
||||
return ""
|
||||
}
|
||||
if col >= (1 << 24) {
|
||||
r := strconv.Itoa((col >> 16) & 0xff)
|
||||
g := strconv.Itoa((col >> 8) & 0xff)
|
||||
b := strconv.Itoa(col & 0xff)
|
||||
return "58;2;" + r + ";" + g + ";" + b + ";"
|
||||
}
|
||||
return "58;5;" + strconv.Itoa(col) + ";"
|
||||
}
|
||||
|
||||
func toAnsiString(color tui.Color, offset int) string {
|
||||
col := int(color)
|
||||
ret := ""
|
||||
@@ -94,31 +124,31 @@ func toAnsiString(color tui.Color, offset int) string {
|
||||
return ret + ";"
|
||||
}
|
||||
|
||||
func isPrint(c uint8) bool {
|
||||
return '\x20' <= c && c <= '\x7e'
|
||||
}
|
||||
|
||||
func matchOperatingSystemCommand(s string, start int) int {
|
||||
// `\x1b][0-9][;:][[:print:]]+(?:\x1b\\\\|\x07)`
|
||||
// ^ match starting here after the first printable character
|
||||
//
|
||||
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) {
|
||||
if s[i] == '\x07' {
|
||||
return i + 1
|
||||
}
|
||||
// `\x1b]8;PARAMS;URI\x1b\\TITLE\x1b]8;;\x1b`
|
||||
// ------
|
||||
if s[i] == '\x1b' && i < len(s)-1 && s[i+1] == '\\' {
|
||||
return i + 2
|
||||
}
|
||||
i += idx
|
||||
|
||||
if s[i] == '\x07' {
|
||||
return i + 1
|
||||
}
|
||||
// `\x1b]8;PARAMS;URI\x1b\\TITLE\x1b]8;;\x1b`
|
||||
// ------
|
||||
if i < len(s)-1 && s[i+1] == '\\' {
|
||||
return i + 2
|
||||
}
|
||||
|
||||
// `\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
|
||||
}
|
||||
|
||||
@@ -204,7 +234,7 @@ Loop:
|
||||
|
||||
// \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 {
|
||||
return i, i + k
|
||||
}
|
||||
@@ -338,15 +368,19 @@ func extractColor(str string, state *ansiState, proc func(string, *ansiState) bo
|
||||
return trimmed, nil, state
|
||||
}
|
||||
|
||||
func parseAnsiCode(s string) (int, string) {
|
||||
func parseAnsiCode(s string) (int, byte, string) {
|
||||
var remaining string
|
||||
var i int
|
||||
// Faster than strings.IndexAny(";:")
|
||||
i = strings.IndexByte(s, ';')
|
||||
if i < 0 {
|
||||
i = strings.IndexByte(s, ':')
|
||||
var sep byte
|
||||
// Find the first separator (either ; or :)
|
||||
i := -1
|
||||
for j := 0; j < len(s); j++ {
|
||||
if s[j] == ';' || s[j] == ':' {
|
||||
i = j
|
||||
break
|
||||
}
|
||||
}
|
||||
if i >= 0 {
|
||||
sep = s[i]
|
||||
remaining = s[i+1:]
|
||||
s = s[:i]
|
||||
}
|
||||
@@ -358,14 +392,14 @@ func parseAnsiCode(s string) (int, string) {
|
||||
for _, ch := range stringBytes(s) {
|
||||
ch -= '0'
|
||||
if ch > 9 {
|
||||
return -1, remaining
|
||||
return -1, sep, remaining
|
||||
}
|
||||
code = code*10 + int(ch)
|
||||
}
|
||||
return code, remaining
|
||||
return code, sep, remaining
|
||||
}
|
||||
|
||||
return -1, remaining
|
||||
return -1, sep, remaining
|
||||
}
|
||||
|
||||
func interpretCode(ansiCode string, prevState *ansiState) ansiState {
|
||||
@@ -373,14 +407,14 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
|
||||
if prevState != nil {
|
||||
return *prevState
|
||||
}
|
||||
return ansiState{-1, -1, 0, -1, nil}
|
||||
return ansiState{-1, -1, -1, 0, -1, nil}
|
||||
}
|
||||
|
||||
var state ansiState
|
||||
if prevState == nil {
|
||||
state = ansiState{-1, -1, 0, -1, nil}
|
||||
state = ansiState{-1, -1, -1, 0, -1, nil}
|
||||
} else {
|
||||
state = ansiState{prevState.fg, prevState.bg, prevState.attr, prevState.lbg, prevState.url}
|
||||
state = ansiState{prevState.fg, prevState.bg, prevState.ul, prevState.attr, prevState.lbg, prevState.url}
|
||||
}
|
||||
if ansiCode[0] != '\x1b' || ansiCode[1] != '[' || ansiCode[len(ansiCode)-1] != 'm' {
|
||||
if prevState != nil && (strings.HasSuffix(ansiCode, "0K") || strings.HasSuffix(ansiCode, "[K")) {
|
||||
@@ -405,6 +439,7 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
|
||||
reset := func() {
|
||||
state.fg = -1
|
||||
state.bg = -1
|
||||
state.ul = -1
|
||||
state.attr = 0
|
||||
}
|
||||
|
||||
@@ -420,7 +455,8 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
|
||||
count := 0
|
||||
for len(ansiCode) != 0 {
|
||||
var num int
|
||||
if num, ansiCode = parseAnsiCode(ansiCode); num != -1 {
|
||||
var sep byte
|
||||
if num, sep, ansiCode = parseAnsiCode(ansiCode); num != -1 {
|
||||
count++
|
||||
switch state256 {
|
||||
case 0:
|
||||
@@ -431,10 +467,15 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
|
||||
case 48:
|
||||
ptr = &state.bg
|
||||
state256++
|
||||
case 58:
|
||||
ptr = &state.ul
|
||||
state256++
|
||||
case 39:
|
||||
state.fg = -1
|
||||
case 49:
|
||||
state.bg = -1
|
||||
case 59:
|
||||
state.ul = -1
|
||||
case 1:
|
||||
state.attr = state.attr | tui.Bold
|
||||
case 2:
|
||||
@@ -442,7 +483,30 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
|
||||
case 3:
|
||||
state.attr = state.attr | tui.Italic
|
||||
case 4:
|
||||
state.attr = state.attr | tui.Underline
|
||||
if sep == ':' {
|
||||
// SGR 4:N - underline style sub-parameter
|
||||
var subNum int
|
||||
subNum, _, ansiCode = parseAnsiCode(ansiCode)
|
||||
state.attr = state.attr &^ tui.UnderlineStyleMask
|
||||
switch subNum {
|
||||
case 0:
|
||||
state.attr = state.attr &^ tui.Underline
|
||||
case 1:
|
||||
state.attr = state.attr | tui.Underline
|
||||
case 2:
|
||||
state.attr = state.attr | tui.Underline | tui.UlStyleDouble
|
||||
case 3:
|
||||
state.attr = state.attr | tui.Underline | tui.UlStyleCurly
|
||||
case 4:
|
||||
state.attr = state.attr | tui.Underline | tui.UlStyleDotted
|
||||
case 5:
|
||||
state.attr = state.attr | tui.Underline | tui.UlStyleDashed
|
||||
default:
|
||||
state.attr = state.attr | tui.Underline
|
||||
}
|
||||
} else {
|
||||
state.attr = state.attr | tui.Underline
|
||||
}
|
||||
case 5:
|
||||
state.attr = state.attr | tui.Blink
|
||||
case 7:
|
||||
@@ -456,6 +520,7 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
|
||||
state.attr = state.attr &^ tui.Italic
|
||||
case 24: // tput rmul
|
||||
state.attr = state.attr &^ tui.Underline
|
||||
state.attr = state.attr &^ tui.UnderlineStyleMask
|
||||
case 25:
|
||||
state.attr = state.attr &^ tui.Blink
|
||||
case 27:
|
||||
|
||||
+123
-17
@@ -369,10 +369,10 @@ func TestAnsiCodeStringConversion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
assert("\x1b[m", nil, "")
|
||||
assert("\x1b[m", &ansiState{attr: tui.Blink, lbg: -1}, "")
|
||||
assert("\x1b[0m", &ansiState{fg: 4, bg: 4, lbg: -1}, "")
|
||||
assert("\x1b[;m", &ansiState{fg: 4, bg: 4, lbg: -1}, "")
|
||||
assert("\x1b[;;m", &ansiState{fg: 4, bg: 4, lbg: -1}, "")
|
||||
assert("\x1b[m", &ansiState{attr: tui.Blink, ul: -1, lbg: -1}, "")
|
||||
assert("\x1b[0m", &ansiState{fg: 4, bg: 4, ul: -1, lbg: -1}, "")
|
||||
assert("\x1b[;m", &ansiState{fg: 4, bg: 4, ul: -1, lbg: -1}, "")
|
||||
assert("\x1b[;;m", &ansiState{fg: 4, bg: 4, ul: -1, lbg: -1}, "")
|
||||
|
||||
assert("\x1b[31m", nil, "\x1b[31;49m")
|
||||
assert("\x1b[41m", nil, "\x1b[39;41m")
|
||||
@@ -380,36 +380,142 @@ func TestAnsiCodeStringConversion(t *testing.T) {
|
||||
assert("\x1b[92m", nil, "\x1b[92;49m")
|
||||
assert("\x1b[102m", nil, "\x1b[39;102m")
|
||||
|
||||
assert("\x1b[31m", &ansiState{fg: 4, bg: 4, lbg: -1}, "\x1b[31;44m")
|
||||
assert("\x1b[1;2;31m", &ansiState{fg: 2, bg: -1, attr: tui.Reverse, lbg: -1}, "\x1b[1;2;7;31;49m")
|
||||
assert("\x1b[31m", &ansiState{fg: 4, bg: 4, ul: -1, lbg: -1}, "\x1b[31;44m")
|
||||
assert("\x1b[1;2;31m", &ansiState{fg: 2, bg: -1, ul: -1, attr: tui.Reverse, lbg: -1}, "\x1b[1;2;7;31;49m")
|
||||
assert("\x1b[38;5;100;48;5;200m", nil, "\x1b[38;5;100;48;5;200m")
|
||||
assert("\x1b[38:5:100:48:5:200m", nil, "\x1b[38;5;100;48;5;200m")
|
||||
assert("\x1b[48;5;100;38;5;200m", nil, "\x1b[38;5;200;48;5;100m")
|
||||
assert("\x1b[48;5;100;38;2;10;20;30;1m", nil, "\x1b[1;38;2;10;20;30;48;5;100m")
|
||||
assert("\x1b[48;5;100;38;2;10;20;30;7m",
|
||||
&ansiState{attr: tui.Dim | tui.Italic, fg: 1, bg: 1},
|
||||
&ansiState{attr: tui.Dim | tui.Italic, fg: 1, bg: 1, ul: -1},
|
||||
"\x1b[2;3;7;38;2;10;20;30;48;5;100m")
|
||||
|
||||
// Underline styles
|
||||
assert("\x1b[4:3m", nil, "\x1b[4:3;39;49m")
|
||||
assert("\x1b[4:2m", nil, "\x1b[4:2;39;49m")
|
||||
assert("\x1b[4:4m", nil, "\x1b[4:4;39;49m")
|
||||
assert("\x1b[4:5m", nil, "\x1b[4:5;39;49m")
|
||||
assert("\x1b[4:1m", nil, "\x1b[4;39;49m")
|
||||
|
||||
// Underline color (256-color)
|
||||
assert("\x1b[4;58;5;100m", nil, "\x1b[4;39;49;58;5;100m")
|
||||
// Underline color (24-bit)
|
||||
assert("\x1b[4;58;2;255;0;128m", nil, "\x1b[4;39;49;58;2;255;0;128m")
|
||||
// Curly underline + underline color
|
||||
assert("\x1b[4:3;58;2;255;0;0m", nil, "\x1b[4:3;39;49;58;2;255;0;0m")
|
||||
// SGR 59 resets underline color
|
||||
assert("\x1b[59m", &ansiState{fg: 1, bg: -1, ul: 100, lbg: -1}, "\x1b[31;49m")
|
||||
}
|
||||
|
||||
func TestParseAnsiCode(t *testing.T) {
|
||||
tests := []struct {
|
||||
In, Exp string
|
||||
N int
|
||||
In string
|
||||
Exp string
|
||||
N int
|
||||
Sep byte
|
||||
}{
|
||||
{"123", "", 123},
|
||||
{"1a", "", -1},
|
||||
{"1a;12", "12", -1},
|
||||
{"12;a", "a", 12},
|
||||
{"-2", "", -1},
|
||||
{"123", "", 123, 0},
|
||||
{"1a", "", -1, 0},
|
||||
{"1a;12", "12", -1, ';'},
|
||||
{"12;a", "a", 12, ';'},
|
||||
{"-2", "", -1, 0},
|
||||
// Colon sub-parameters: earliest separator wins (@shtse8)
|
||||
{"4:3", "3", 4, ':'},
|
||||
{"4:3;31", "3;31", 4, ':'},
|
||||
{"38:2:255:0:0", "2:255:0:0", 38, ':'},
|
||||
{"58:5:200", "5:200", 58, ':'},
|
||||
// Semicolon before colon
|
||||
{"4;38:2:0:0:0", "38:2:0:0:0", 4, ';'},
|
||||
}
|
||||
for _, x := range tests {
|
||||
n, s := parseAnsiCode(x.In)
|
||||
if n != x.N || s != x.Exp {
|
||||
t.Fatalf("%q: got: (%d %q) want: (%d %q)", x.In, n, s, x.N, x.Exp)
|
||||
n, sep, s := parseAnsiCode(x.In)
|
||||
if n != x.N || s != x.Exp || sep != x.Sep {
|
||||
t.Fatalf("%q: got: (%d %q %q) want: (%d %q %q)", x.In, n, s, string(sep), x.N, x.Exp, string(x.Sep))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test cases adapted from @shtse8 (PR #4678)
|
||||
func TestInterpretCodeUnderlineStyles(t *testing.T) {
|
||||
// 4:0 = no underline
|
||||
state := interpretCode("\x1b[4:0m", nil)
|
||||
if state.attr&tui.Underline != 0 {
|
||||
t.Error("4:0 should not set underline")
|
||||
}
|
||||
|
||||
// 4:1 = single underline
|
||||
state = interpretCode("\x1b[4:1m", nil)
|
||||
if state.attr&tui.Underline == 0 {
|
||||
t.Error("4:1 should set underline")
|
||||
}
|
||||
|
||||
// 4:3 = curly underline
|
||||
state = interpretCode("\x1b[4:3m", nil)
|
||||
if state.attr&tui.Underline == 0 {
|
||||
t.Error("4:3 should set underline")
|
||||
}
|
||||
if state.attr.UnderlineStyle() != tui.UlStyleCurly {
|
||||
t.Error("4:3 should set curly underline style")
|
||||
}
|
||||
|
||||
// 4:3 should NOT set italic (3 is a sub-param, not SGR 3)
|
||||
if state.attr&tui.Italic != 0 {
|
||||
t.Error("4:3 should not set italic")
|
||||
}
|
||||
|
||||
// 4:2;31 = double underline + red fg
|
||||
state = interpretCode("\x1b[4:2;31m", nil)
|
||||
if state.attr&tui.Underline == 0 {
|
||||
t.Error("4:2;31 should set underline")
|
||||
}
|
||||
if state.fg != 1 {
|
||||
t.Errorf("4:2;31 should set fg to red (1), got %d", state.fg)
|
||||
}
|
||||
if state.attr&tui.Dim != 0 {
|
||||
t.Error("4:2;31 should not set dim")
|
||||
}
|
||||
|
||||
// Plain 4 still works
|
||||
state = interpretCode("\x1b[4m", nil)
|
||||
if state.attr&tui.Underline == 0 {
|
||||
t.Error("4 should set underline")
|
||||
}
|
||||
|
||||
// 4;2 (semicolon) = underline + dim
|
||||
state = interpretCode("\x1b[4;2m", nil)
|
||||
if state.attr&tui.Underline == 0 {
|
||||
t.Error("4;2 should set underline")
|
||||
}
|
||||
if state.attr&tui.Dim == 0 {
|
||||
t.Error("4;2 should set dim")
|
||||
}
|
||||
}
|
||||
|
||||
// Test cases adapted from @shtse8 (PR #4678)
|
||||
func TestInterpretCodeUnderlineColor(t *testing.T) {
|
||||
// 58:2:R:G:B should not affect fg or bg
|
||||
state := interpretCode("\x1b[58:2:255:0:0m", nil)
|
||||
if state.fg != -1 || state.bg != -1 {
|
||||
t.Errorf("58:2:R:G:B should not affect fg/bg, got fg=%d bg=%d", state.fg, state.bg)
|
||||
}
|
||||
|
||||
// 58:5:200 should not affect fg or bg
|
||||
state = interpretCode("\x1b[58:5:200m", nil)
|
||||
if state.fg != -1 || state.bg != -1 {
|
||||
t.Errorf("58:5:N should not affect fg/bg, got fg=%d bg=%d", state.fg, state.bg)
|
||||
}
|
||||
|
||||
// 58:2:R:G:B combined with 38:2:R:G:B should only set fg
|
||||
state = interpretCode("\x1b[58:2:255:0:0;38:2:0:255:0m", nil)
|
||||
expectedFg := tui.Color(1<<24 | 0<<16 | 255<<8 | 0)
|
||||
if state.fg != expectedFg {
|
||||
t.Errorf("expected fg=%d, got %d", expectedFg, state.fg)
|
||||
}
|
||||
if state.bg != -1 {
|
||||
t.Errorf("bg should be -1, got %d", state.bg)
|
||||
}
|
||||
}
|
||||
|
||||
// kernel/bpf/preload/iterators/README
|
||||
const ansiBenchmarkString = "\x1b[38;5;81m\x1b[01;31m\x1b[Kkernel/\x1b[0m\x1b[38:5:81mbpf/" +
|
||||
"\x1b[0m\x1b[38:5:81mpreload/\x1b[0m\x1b[38;5;81miterators/" +
|
||||
|
||||
+18
-15
@@ -2,10 +2,13 @@ package fzf
|
||||
|
||||
import "sync"
|
||||
|
||||
// queryCache associates strings to lists of items
|
||||
type queryCache map[string][]Result
|
||||
// ChunkBitmap is a bitmap with one bit per item in a chunk.
|
||||
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 {
|
||||
mutex sync.Mutex
|
||||
cache map[*Chunk]*queryCache
|
||||
@@ -30,9 +33,9 @@ func (cc *ChunkCache) retire(chunk ...*Chunk) {
|
||||
cc.mutex.Unlock()
|
||||
}
|
||||
|
||||
// Add adds the list to the cache
|
||||
func (cc *ChunkCache) Add(chunk *Chunk, key string, list []Result) {
|
||||
if len(key) == 0 || !chunk.IsFull() || len(list) > queryCacheMax {
|
||||
// Add stores the bitmap for the given chunk and key
|
||||
func (cc *ChunkCache) Add(chunk *Chunk, key string, bitmap ChunkBitmap, matchCount int) {
|
||||
if len(key) == 0 || !chunk.IsFull() || matchCount > queryCacheMax {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -44,11 +47,11 @@ func (cc *ChunkCache) Add(chunk *Chunk, key string, list []Result) {
|
||||
cc.cache[chunk] = &queryCache{}
|
||||
qc = cc.cache[chunk]
|
||||
}
|
||||
(*qc)[key] = list
|
||||
(*qc)[key] = bitmap
|
||||
}
|
||||
|
||||
// Lookup is called to lookup ChunkCache
|
||||
func (cc *ChunkCache) Lookup(chunk *Chunk, key string) []Result {
|
||||
// Lookup returns the bitmap for the exact key
|
||||
func (cc *ChunkCache) Lookup(chunk *Chunk, key string) *ChunkBitmap {
|
||||
if len(key) == 0 || !chunk.IsFull() {
|
||||
return nil
|
||||
}
|
||||
@@ -58,15 +61,15 @@ func (cc *ChunkCache) Lookup(chunk *Chunk, key string) []Result {
|
||||
|
||||
qc, ok := cc.cache[chunk]
|
||||
if ok {
|
||||
list, ok := (*qc)[key]
|
||||
if ok {
|
||||
return list
|
||||
if bm, ok := (*qc)[key]; ok {
|
||||
return &bm
|
||||
}
|
||||
}
|
||||
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() {
|
||||
return nil
|
||||
}
|
||||
@@ -86,8 +89,8 @@ func (cc *ChunkCache) Search(chunk *Chunk, key string) []Result {
|
||||
prefix := key[:len(key)-idx]
|
||||
suffix := key[idx:]
|
||||
for _, substr := range [2]string{prefix, suffix} {
|
||||
if cached, found := (*qc)[substr]; found {
|
||||
return cached
|
||||
if bm, found := (*qc)[substr]; found {
|
||||
return &bm
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-11
@@ -6,34 +6,34 @@ func TestChunkCache(t *testing.T) {
|
||||
cache := NewChunkCache()
|
||||
chunk1p := &Chunk{}
|
||||
chunk2p := &Chunk{count: chunkSize}
|
||||
items1 := []Result{{}}
|
||||
items2 := []Result{{}, {}}
|
||||
cache.Add(chunk1p, "foo", items1)
|
||||
cache.Add(chunk2p, "foo", items1)
|
||||
cache.Add(chunk2p, "bar", items2)
|
||||
bm1 := ChunkBitmap{1}
|
||||
bm2 := ChunkBitmap{1, 2}
|
||||
cache.Add(chunk1p, "foo", bm1, 1)
|
||||
cache.Add(chunk2p, "foo", bm1, 1)
|
||||
cache.Add(chunk2p, "bar", bm2, 2)
|
||||
|
||||
{ // chunk1 is not full
|
||||
cached := cache.Lookup(chunk1p, "foo")
|
||||
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")
|
||||
if cached == nil || len(cached) != 1 {
|
||||
t.Error("Expected 1 item cached", cached)
|
||||
if cached == nil || cached[0] != 1 {
|
||||
t.Error("Expected bitmap cached", cached)
|
||||
}
|
||||
}
|
||||
{
|
||||
cached := cache.Lookup(chunk2p, "bar")
|
||||
if cached == nil || len(cached) != 2 {
|
||||
t.Error("Expected 2 items cached", cached)
|
||||
if cached == nil || cached[1] != 2 {
|
||||
t.Error("Expected bitmap cached", cached)
|
||||
}
|
||||
}
|
||||
{
|
||||
cached := cache.Lookup(chunk1p, "foobar")
|
||||
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]
|
||||
}
|
||||
|
||||
// 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
|
||||
func CountItems(cs []*Chunk) int {
|
||||
if len(cs) == 0 {
|
||||
@@ -85,6 +99,21 @@ func (cl *ChunkList) Clear() {
|
||||
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
|
||||
func (cl *ChunkList) Snapshot(tail int) ([]*Chunk, int, bool) {
|
||||
cl.mutex.Lock()
|
||||
|
||||
+4
-6
@@ -34,19 +34,18 @@ const (
|
||||
maxBgProcessesPerAction = 3
|
||||
|
||||
// Matcher
|
||||
numPartitionsMultiplier = 8
|
||||
maxPartitions = 32
|
||||
progressMinDuration = 200 * time.Millisecond
|
||||
progressMinDuration = 200 * time.Millisecond
|
||||
|
||||
// Capacity of each chunk
|
||||
chunkSize int = 100
|
||||
chunkSize int = 1024
|
||||
chunkBitWords = (chunkSize + 63) / 64
|
||||
|
||||
// Pre-allocated memory slices to minimize GC
|
||||
slab16Size int = 100 * 1024 // 200KB * 32 = 12.8MB
|
||||
slab32Size int = 2048 // 8KB * 32 = 256KB
|
||||
|
||||
// Do not cache results of low selectivity queries
|
||||
queryCacheMax int = chunkSize / 5
|
||||
queryCacheMax int = chunkSize / 2
|
||||
|
||||
// Not to cache mergers with large lists
|
||||
mergerCacheMax int = 100000
|
||||
@@ -65,7 +64,6 @@ const (
|
||||
EvtSearchNew
|
||||
EvtSearchProgress
|
||||
EvtSearchFin
|
||||
EvtHeader
|
||||
EvtReady
|
||||
EvtQuit
|
||||
)
|
||||
|
||||
+178
-68
@@ -2,7 +2,9 @@
|
||||
package fzf
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"math"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -17,7 +19,6 @@ Reader -> EvtReadNew -> Matcher (restart)
|
||||
Terminal -> EvtSearchNew:bool -> Matcher (restart)
|
||||
Matcher -> EvtSearchProgress -> Terminal (update info)
|
||||
Matcher -> EvtSearchFin -> Terminal (update list)
|
||||
Matcher -> EvtHeader -> Terminal (update header)
|
||||
*/
|
||||
|
||||
type revision struct {
|
||||
@@ -38,12 +39,27 @@ func (r revision) compatible(other revision) bool {
|
||||
return r.major == other.major
|
||||
}
|
||||
|
||||
func buildItemTransformer(opts *Options) func(*Item) string {
|
||||
if opts.AcceptNth != nil {
|
||||
fn := opts.AcceptNth(opts.Delimiter)
|
||||
return func(item *Item) string {
|
||||
return item.acceptNth(opts.Ansi, opts.Delimiter, fn)
|
||||
}
|
||||
}
|
||||
return func(item *Item) string {
|
||||
return item.AsString(opts.Ansi)
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts fzf
|
||||
func Run(opts *Options) (int, error) {
|
||||
if opts.Filter == nil {
|
||||
if opts.useTmux() {
|
||||
return runTmux(os.Args, opts)
|
||||
}
|
||||
if opts.useZellij() {
|
||||
return runZellij(os.Args, opts)
|
||||
}
|
||||
|
||||
if needWinpty(opts) {
|
||||
return runWinpty(os.Args, opts)
|
||||
@@ -101,57 +117,57 @@ func Run(opts *Options) (int, error) {
|
||||
cache := NewChunkCache()
|
||||
var chunkList *ChunkList
|
||||
var itemIndex int32
|
||||
header := make([]string, 0, opts.HeaderLines)
|
||||
// transformItem applies with-nth transformation to an item's raw data.
|
||||
// It handles ANSI token propagation using prevLineAnsiState for cross-line continuity.
|
||||
transformItem := func(item *Item, data []byte, transformer func([]Token, int32) string, index int32) {
|
||||
tokens := Tokenize(byteString(data), opts.Delimiter)
|
||||
if opts.Ansi && len(tokens) > 1 {
|
||||
var ansiState *ansiState
|
||||
if prevLineAnsiState != nil {
|
||||
ansiStateDup := *prevLineAnsiState
|
||||
ansiState = &ansiStateDup
|
||||
}
|
||||
for _, token := range tokens {
|
||||
prevAnsiState := ansiState
|
||||
_, _, ansiState = extractColor(token.text.ToString(), ansiState, nil)
|
||||
if prevAnsiState != nil {
|
||||
token.text.Prepend("\x1b[m" + prevAnsiState.ToString())
|
||||
} else {
|
||||
token.text.Prepend("\x1b[m")
|
||||
}
|
||||
}
|
||||
}
|
||||
transformed := transformer(tokens, index)
|
||||
item.text, item.colors = ansiProcessor(stringBytes(transformed))
|
||||
|
||||
// We should not trim trailing whitespaces with background colors
|
||||
var maxColorOffset int32
|
||||
if item.colors != nil {
|
||||
for _, ansi := range *item.colors {
|
||||
if ansi.color.bg >= 0 {
|
||||
maxColorOffset = max(maxColorOffset, ansi.offset[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
item.text.TrimTrailingWhitespaces(int(maxColorOffset))
|
||||
}
|
||||
|
||||
var nthTransformer func([]Token, int32) string
|
||||
if opts.WithNth == nil {
|
||||
chunkList = NewChunkList(cache, func(item *Item, data []byte) bool {
|
||||
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)
|
||||
nthTransformer = opts.WithNth(opts.Delimiter)
|
||||
chunkList = NewChunkList(cache, func(item *Item, data []byte) bool {
|
||||
tokens := Tokenize(byteString(data), opts.Delimiter)
|
||||
if opts.Ansi && len(tokens) > 1 {
|
||||
var ansiState *ansiState
|
||||
if prevLineAnsiState != nil {
|
||||
ansiStateDup := *prevLineAnsiState
|
||||
ansiState = &ansiStateDup
|
||||
}
|
||||
for _, token := range tokens {
|
||||
prevAnsiState := ansiState
|
||||
_, _, ansiState = extractColor(token.text.ToString(), ansiState, nil)
|
||||
if prevAnsiState != nil {
|
||||
token.text.Prepend("\x1b[m" + prevAnsiState.ToString())
|
||||
} else {
|
||||
token.text.Prepend("\x1b[m")
|
||||
}
|
||||
}
|
||||
if nthTransformer == nil {
|
||||
item.text, item.colors = ansiProcessor(data)
|
||||
} else {
|
||||
transformItem(item, data, nthTransformer, itemIndex)
|
||||
}
|
||||
transformed := nthTransformer(tokens, itemIndex)
|
||||
if len(header) < opts.HeaderLines {
|
||||
header = append(header, transformed)
|
||||
eventBox.Set(EvtHeader, header)
|
||||
return false
|
||||
}
|
||||
item.text, item.colors = ansiProcessor(stringBytes(transformed))
|
||||
|
||||
// We should not trim trailing whitespaces with background colors
|
||||
var maxColorOffset int32
|
||||
if item.colors != nil {
|
||||
for _, ansi := range *item.colors {
|
||||
if ansi.color.bg >= 0 {
|
||||
maxColorOffset = util.Max32(maxColorOffset, ansi.offset[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
item.text.TrimTrailingWhitespaces(int(maxColorOffset))
|
||||
item.text.Index = itemIndex
|
||||
item.origText = &data
|
||||
itemIndex++
|
||||
@@ -181,13 +197,15 @@ func Run(opts *Options) (int, error) {
|
||||
}
|
||||
|
||||
// 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 ingestionStart time.Time
|
||||
if !streamingFilter {
|
||||
reader = NewReader(func(data []byte) bool {
|
||||
return chunkList.Push(data)
|
||||
}, eventBox, executor, opts.ReadZero, opts.Filter == nil)
|
||||
|
||||
ingestionStart = time.Now()
|
||||
readyChan := make(chan bool)
|
||||
go reader.ReadSource(opts.Input, opts.WalkerRoot, opts.WalkerOpts, opts.WalkerSkip, initialReload, initialEnv, readyChan)
|
||||
<-readyChan
|
||||
@@ -224,15 +242,20 @@ func Run(opts *Options) (int, error) {
|
||||
denylist = make(map[int32]struct{})
|
||||
denyMutex.Unlock()
|
||||
}
|
||||
if opts.HeaderLines > math.MaxInt32 {
|
||||
opts.HeaderLines = math.MaxInt32
|
||||
}
|
||||
headerLines := int32(opts.HeaderLines)
|
||||
headerUpdated := false
|
||||
patternBuilder := func(runes []rune) *Pattern {
|
||||
denyMutex.Lock()
|
||||
denylistCopy := maps.Clone(denylist)
|
||||
denyMutex.Unlock()
|
||||
return BuildPattern(cache, patternCache,
|
||||
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
|
||||
if opts.Filter != nil {
|
||||
@@ -243,6 +266,8 @@ func Run(opts *Options) (int, error) {
|
||||
pattern := patternBuilder([]rune(*opts.Filter))
|
||||
matcher.sort = pattern.sortable
|
||||
|
||||
transformer := buildItemTransformer(opts)
|
||||
|
||||
found := false
|
||||
if streamingFilter {
|
||||
slab := util.MakeSlab(slab16Size, slab32Size)
|
||||
@@ -251,9 +276,12 @@ func Run(opts *Options) (int, error) {
|
||||
func(runes []byte) bool {
|
||||
item := Item{}
|
||||
if chunkList.trans(&item, runes) {
|
||||
if item.Index() < headerLines {
|
||||
return false
|
||||
}
|
||||
mutex.Lock()
|
||||
if result, _, _ := pattern.MatchItem(&item, false, slab); result != nil {
|
||||
opts.Printer(item.text.ToString())
|
||||
if result, _, _ := pattern.MatchItem(&item, false, slab); result.item != nil {
|
||||
opts.Printer(transformer(&item))
|
||||
found = true
|
||||
}
|
||||
mutex.Unlock()
|
||||
@@ -264,14 +292,56 @@ func Run(opts *Options) (int, error) {
|
||||
} else {
|
||||
eventBox.Unwatch(EvtReadNew)
|
||||
eventBox.WaitFor(EvtReadFin)
|
||||
ingestionTime := time.Since(ingestionStart)
|
||||
|
||||
// NOTE: Streaming filter is inherently not compatible with --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{
|
||||
chunks: snapshot,
|
||||
pattern: pattern})
|
||||
for i := 0; i < result.merger.Length(); i++ {
|
||||
opts.Printer(result.merger.Get(i).item.AsString(opts.Ansi))
|
||||
opts.Printer(transformer(result.merger.Get(i).item))
|
||||
found = true
|
||||
}
|
||||
}
|
||||
@@ -316,10 +386,11 @@ func Run(opts *Options) (int, error) {
|
||||
query := []rune{}
|
||||
determine := func(final bool) {
|
||||
if heightUnknown {
|
||||
if total >= maxFit || final {
|
||||
items := max(0, total-int(headerLines))
|
||||
if items >= maxFit || final {
|
||||
deferred = false
|
||||
heightUnknown = false
|
||||
terminal.startChan <- fitpad{util.Min(total, maxFit), padHeight}
|
||||
terminal.startChan <- fitpad{min(items, maxFit), padHeight}
|
||||
}
|
||||
} else if deferred {
|
||||
deferred = false
|
||||
@@ -335,11 +406,11 @@ func Run(opts *Options) (int, error) {
|
||||
clearDenylist()
|
||||
}
|
||||
reading = true
|
||||
headerUpdated = false
|
||||
startTick = ticks
|
||||
chunkList.Clear()
|
||||
itemIndex = 0
|
||||
inputRevision.bumpMajor()
|
||||
header = make([]string, 0, opts.HeaderLines)
|
||||
readyChan := make(chan bool)
|
||||
go reader.restart(command, environ, readyChan)
|
||||
<-readyChan
|
||||
@@ -397,16 +468,24 @@ func Run(opts *Options) (int, error) {
|
||||
snapshotRevision = inputRevision
|
||||
}
|
||||
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 {
|
||||
determine(!reading)
|
||||
}
|
||||
matcher.Reset(snapshot, input(), false, !reading, sort, snapshotRevision)
|
||||
if !useSnapshot || evt == EvtReadFin {
|
||||
matcher.Reset(snapshot, input(), false, !reading, sort, snapshotRevision)
|
||||
}
|
||||
|
||||
case EvtSearchNew:
|
||||
var command *commandSpec
|
||||
var environ []string
|
||||
var changed bool
|
||||
headerLinesChanged := false
|
||||
withNthChanged := false
|
||||
switch val := value.(type) {
|
||||
case searchRequest:
|
||||
sort = val.sort
|
||||
@@ -427,6 +506,40 @@ func Run(opts *Options) (int, error) {
|
||||
nth = *val.nth
|
||||
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 {
|
||||
patternCache = make(map[string]*Pattern)
|
||||
cache.Clear()
|
||||
@@ -463,6 +576,16 @@ func Run(opts *Options) (int, error) {
|
||||
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)
|
||||
delay = false
|
||||
|
||||
@@ -472,11 +595,6 @@ func Run(opts *Options) (int, error) {
|
||||
terminal.UpdateProgress(val)
|
||||
}
|
||||
|
||||
case EvtHeader:
|
||||
headerPadded := make([]string, opts.HeaderLines)
|
||||
copy(headerPadded, value.([]string))
|
||||
terminal.UpdateHeader(headerPadded)
|
||||
|
||||
case EvtSearchFin:
|
||||
switch val := value.(type) {
|
||||
case MatchResult:
|
||||
@@ -493,15 +611,7 @@ func Run(opts *Options) (int, error) {
|
||||
if len(opts.Expect) > 0 {
|
||||
opts.Printer("")
|
||||
}
|
||||
transformer := func(item *Item) string {
|
||||
return item.AsString(opts.Ansi)
|
||||
}
|
||||
if opts.AcceptNth != nil {
|
||||
fn := opts.AcceptNth(opts.Delimiter)
|
||||
transformer = func(item *Item) string {
|
||||
return item.acceptNth(opts.Ansi, opts.Delimiter, fn)
|
||||
}
|
||||
}
|
||||
transformer := buildItemTransformer(opts)
|
||||
for i := range count {
|
||||
opts.Printer(transformer(merger.Get(i).item))
|
||||
}
|
||||
@@ -524,7 +634,7 @@ func Run(opts *Options) (int, error) {
|
||||
break
|
||||
}
|
||||
if delay && reading {
|
||||
dur := util.DurWithin(
|
||||
dur := util.Constrain(
|
||||
time.Duration(ticks-startTick)*coordinatorDelayStep,
|
||||
0, coordinatorDelayMax)
|
||||
time.Sleep(dur)
|
||||
|
||||
+50
-53
@@ -3,8 +3,8 @@ package fzf
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/junegunn/fzf/src/util"
|
||||
@@ -43,8 +43,11 @@ type Matcher struct {
|
||||
reqBox *util.EventBox
|
||||
partitions int
|
||||
slab []*util.Slab
|
||||
sortBuf [][]Result
|
||||
mergerCache map[string]MatchResult
|
||||
revision revision
|
||||
scanMutex sync.Mutex
|
||||
cancelScan *util.AtomicBool
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -54,8 +57,11 @@ const (
|
||||
|
||||
// NewMatcher returns a new Matcher
|
||||
func NewMatcher(cache *ChunkCache, patternBuilder func([]rune) *Pattern,
|
||||
sort bool, tac bool, eventBox *util.EventBox, revision revision) *Matcher {
|
||||
partitions := util.Min(numPartitionsMultiplier*runtime.NumCPU(), maxPartitions)
|
||||
sort bool, tac bool, eventBox *util.EventBox, revision revision, threads int) *Matcher {
|
||||
partitions := runtime.NumCPU()
|
||||
if threads > 0 {
|
||||
partitions = threads
|
||||
}
|
||||
return &Matcher{
|
||||
cache: cache,
|
||||
patternBuilder: patternBuilder,
|
||||
@@ -65,8 +71,10 @@ func NewMatcher(cache *ChunkCache, patternBuilder func([]rune) *Pattern,
|
||||
reqBox: util.NewEventBox(),
|
||||
partitions: partitions,
|
||||
slab: make([]*util.Slab, partitions),
|
||||
sortBuf: make([][]Result, partitions),
|
||||
mergerCache: make(map[string]MatchResult),
|
||||
revision: revision}
|
||||
revision: revision,
|
||||
cancelScan: util.NewAtomicBool(false)}
|
||||
}
|
||||
|
||||
// Loop puts Matcher in action
|
||||
@@ -126,7 +134,9 @@ func (m *Matcher) Loop() {
|
||||
}
|
||||
|
||||
if result.merger == nil {
|
||||
m.scanMutex.Lock()
|
||||
result = m.scan(request)
|
||||
m.scanMutex.Unlock()
|
||||
}
|
||||
|
||||
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 {
|
||||
index int
|
||||
matches []Result
|
||||
@@ -174,7 +163,7 @@ func (m *Matcher) scan(request MatchRequest) MatchResult {
|
||||
return MatchResult{m, m, false}
|
||||
}
|
||||
pattern := request.pattern
|
||||
passMerger := PassMerger(&request.chunks, m.tac, request.revision)
|
||||
passMerger := PassMerger(&request.chunks, m.tac, request.revision, pattern.startIndex)
|
||||
if pattern.IsEmpty() {
|
||||
return MatchResult{passMerger, passMerger, false}
|
||||
}
|
||||
@@ -183,43 +172,37 @@ func (m *Matcher) scan(request MatchRequest) MatchResult {
|
||||
maxIndex := request.chunks[numChunks-1].lastIndex(minIndex)
|
||||
cancelled := util.NewAtomicBool(false)
|
||||
|
||||
slices := m.sliceChunks(request.chunks)
|
||||
numSlices := len(slices)
|
||||
resultChan := make(chan partialResult, numSlices)
|
||||
numWorkers := min(m.partitions, numChunks)
|
||||
var nextChunk atomic.Int32
|
||||
resultChan := make(chan partialResult, numWorkers)
|
||||
countChan := make(chan int, numChunks)
|
||||
waitGroup := sync.WaitGroup{}
|
||||
|
||||
for idx, chunks := range slices {
|
||||
for idx := range numWorkers {
|
||||
waitGroup.Add(1)
|
||||
if m.slab[idx] == nil {
|
||||
m.slab[idx] = util.MakeSlab(slab16Size, slab32Size)
|
||||
}
|
||||
go func(idx int, slab *util.Slab, chunks []*Chunk) {
|
||||
defer func() { waitGroup.Done() }()
|
||||
count := 0
|
||||
allMatches := make([][]Result, len(chunks))
|
||||
for idx, chunk := range chunks {
|
||||
matches := request.pattern.Match(chunk, slab)
|
||||
allMatches[idx] = matches
|
||||
count += len(matches)
|
||||
go func(idx int, slab *util.Slab) {
|
||||
defer waitGroup.Done()
|
||||
var matches []Result
|
||||
for {
|
||||
ci := int(nextChunk.Add(1)) - 1
|
||||
if ci >= numChunks {
|
||||
break
|
||||
}
|
||||
chunkMatches := request.pattern.Match(request.chunks[ci], slab)
|
||||
matches = append(matches, chunkMatches...)
|
||||
if cancelled.Get() {
|
||||
return
|
||||
}
|
||||
countChan <- len(matches)
|
||||
}
|
||||
sliceMatches := make([]Result, 0, count)
|
||||
for _, matches := range allMatches {
|
||||
sliceMatches = append(sliceMatches, matches...)
|
||||
countChan <- len(chunkMatches)
|
||||
}
|
||||
if m.sort && request.pattern.sortable {
|
||||
if m.tac {
|
||||
sort.Sort(ByRelevanceTac(sliceMatches))
|
||||
} else {
|
||||
sort.Sort(ByRelevance(sliceMatches))
|
||||
}
|
||||
m.sortBuf[idx] = radixSortResults(matches, m.tac, m.sortBuf[idx])
|
||||
}
|
||||
resultChan <- partialResult{idx, sliceMatches}
|
||||
}(idx, m.slab[idx], chunks)
|
||||
resultChan <- partialResult{idx, matches}
|
||||
}(idx, m.slab[idx])
|
||||
}
|
||||
|
||||
wait := func() bool {
|
||||
@@ -238,7 +221,7 @@ func (m *Matcher) scan(request MatchRequest) MatchResult {
|
||||
break
|
||||
}
|
||||
|
||||
if m.reqBox.Peek(reqReset) {
|
||||
if m.cancelScan.Get() || m.reqBox.Peek(reqReset) {
|
||||
return MatchResult{nil, nil, wait()}
|
||||
}
|
||||
|
||||
@@ -247,8 +230,8 @@ func (m *Matcher) scan(request MatchRequest) MatchResult {
|
||||
}
|
||||
}
|
||||
|
||||
partialResults := make([][]Result, numSlices)
|
||||
for range slices {
|
||||
partialResults := make([][]Result, numWorkers)
|
||||
for range numWorkers {
|
||||
partialResult := <-resultChan
|
||||
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})
|
||||
}
|
||||
|
||||
// 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() {
|
||||
m.reqBox.Set(reqQuit, nil)
|
||||
}
|
||||
|
||||
+30
-32
@@ -10,42 +10,46 @@ func EmptyMerger(revision revision) *Merger {
|
||||
// Merger holds a set of locally sorted lists of items and provides the view of
|
||||
// a single, globally-sorted list
|
||||
type Merger struct {
|
||||
pattern *Pattern
|
||||
lists [][]Result
|
||||
merged []Result
|
||||
chunks *[]*Chunk
|
||||
cursors []int
|
||||
sorted bool
|
||||
tac bool
|
||||
final bool
|
||||
count int
|
||||
pass bool
|
||||
revision revision
|
||||
minIndex int32
|
||||
maxIndex int32
|
||||
pattern *Pattern
|
||||
lists [][]Result
|
||||
merged []Result
|
||||
chunks *[]*Chunk
|
||||
cursors []int
|
||||
sorted bool
|
||||
tac bool
|
||||
final bool
|
||||
count int
|
||||
pass bool
|
||||
startIndex int
|
||||
revision revision
|
||||
minIndex int32
|
||||
maxIndex int32
|
||||
}
|
||||
|
||||
// PassMerger returns a new Merger that simply returns the items in the
|
||||
// original order
|
||||
func PassMerger(chunks *[]*Chunk, tac bool, revision revision) *Merger {
|
||||
// original order. startIndex items are skipped from the beginning.
|
||||
func PassMerger(chunks *[]*Chunk, tac bool, revision revision, startIndex int32) *Merger {
|
||||
var minIndex, maxIndex int32
|
||||
if len(*chunks) > 0 {
|
||||
minIndex = (*chunks)[0].items[0].Index()
|
||||
maxIndex = (*chunks)[len(*chunks)-1].lastIndex(minIndex)
|
||||
}
|
||||
si := int(startIndex)
|
||||
mg := Merger{
|
||||
pattern: nil,
|
||||
chunks: chunks,
|
||||
tac: tac,
|
||||
count: 0,
|
||||
pass: true,
|
||||
revision: revision,
|
||||
minIndex: minIndex,
|
||||
maxIndex: maxIndex}
|
||||
pattern: nil,
|
||||
chunks: chunks,
|
||||
tac: tac,
|
||||
count: 0,
|
||||
pass: true,
|
||||
startIndex: si,
|
||||
revision: revision,
|
||||
minIndex: minIndex + startIndex,
|
||||
maxIndex: maxIndex}
|
||||
|
||||
for _, chunk := range *mg.chunks {
|
||||
mg.count += chunk.count
|
||||
}
|
||||
mg.count = max(0, mg.count-si)
|
||||
return &mg
|
||||
}
|
||||
|
||||
@@ -113,6 +117,7 @@ func (mg *Merger) Get(idx int) Result {
|
||||
if mg.tac {
|
||||
idx = mg.count - idx - 1
|
||||
}
|
||||
idx += mg.startIndex
|
||||
firstChunk := (*mg.chunks)[0]
|
||||
if firstChunk.count < chunkSize && idx >= firstChunk.count {
|
||||
idx -= firstChunk.count
|
||||
@@ -131,14 +136,7 @@ func (mg *Merger) Get(idx int) Result {
|
||||
if mg.tac {
|
||||
idx = mg.count - idx - 1
|
||||
}
|
||||
for _, list := range mg.lists {
|
||||
numItems := len(list)
|
||||
if idx < numItems {
|
||||
return list[idx]
|
||||
}
|
||||
idx -= numItems
|
||||
}
|
||||
panic(fmt.Sprintf("Index out of bounds (unsorted, %d/%d)", idx, mg.count))
|
||||
return mg.mergedGet(idx)
|
||||
}
|
||||
|
||||
func (mg *Merger) ToMap() map[int32]Result {
|
||||
@@ -166,7 +164,7 @@ func (mg *Merger) mergedGet(idx int) Result {
|
||||
}
|
||||
if cursor >= 0 {
|
||||
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
|
||||
minIdx = listIdx
|
||||
}
|
||||
|
||||
+17
-2
@@ -54,10 +54,25 @@ func buildLists(partiallySorted bool) ([][]Result, []Result) {
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// Not sorted: same order
|
||||
// Not sorted: items in ascending index order
|
||||
mg := NewMerger(nil, lists, false, false, revision{}, 0, 0)
|
||||
assert(t, cnt == mg.Length(), "Invalid Length")
|
||||
for i := range cnt {
|
||||
|
||||
+228
-47
@@ -4,11 +4,12 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"math"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
"time"
|
||||
|
||||
"github.com/junegunn/fzf/src/algo"
|
||||
"github.com/junegunn/fzf/src/tui"
|
||||
@@ -65,7 +66,7 @@ Usage: fzf [options]
|
||||
--no-bold Do not use bold text
|
||||
|
||||
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.
|
||||
A negative value is calculated as the terminal height
|
||||
minus the given value.
|
||||
@@ -74,16 +75,17 @@ Usage: fzf [options]
|
||||
--min-height=HEIGHT[+] Minimum height when --height is given as a percentage.
|
||||
Add '+' to automatically increase the value
|
||||
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 popup window (requires tmux 3.3+ or Zellij 0.44+)
|
||||
[center|top|bottom|left|right][,SIZE[%]][,SIZE[%]]
|
||||
[,border-native] (default: center,50%)
|
||||
--tmux[=OPTS] Alias for --popup
|
||||
|
||||
LAYOUT
|
||||
--layout=LAYOUT Choose layout: [default|reverse|reverse-list]
|
||||
--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)
|
||||
--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)
|
||||
--border-label=LABEL Label to print on the border
|
||||
--border-label-pos=COL Position of the border label
|
||||
@@ -95,11 +97,12 @@ Usage: fzf [options]
|
||||
-m, --multi[=MAX] Enable multi-select with tab/shift-tab
|
||||
--highlight-line Highlight the whole current line
|
||||
--cycle Enable cyclic scroll
|
||||
--wrap Enable line wrap
|
||||
--wrap[=MODE] Enable line wrap (char|word, default: char)
|
||||
--wrap-sign=STR Indicator for wrapped lines
|
||||
--no-multi-line Disable multi-line display of items when using --read0
|
||||
--raw Enable raw mode (show non-matching items)
|
||||
--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
|
||||
--gap[=N] Render empty lines between each item
|
||||
--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)
|
||||
--no-scrollbar Hide scrollbar
|
||||
--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)
|
||||
--list-label=LABEL Label to print on the list border
|
||||
--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
|
||||
--filepath-word Make word-wise movements respect path separators
|
||||
--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)
|
||||
--input-label=LABEL Label to print on the input border
|
||||
--input-label-pos=COL Position of the input label
|
||||
@@ -156,28 +159,30 @@ Usage: fzf [options]
|
||||
PREVIEW WINDOW
|
||||
--preview=COMMAND Command to preview highlighted line ({})
|
||||
--preview-window=OPT Preview window layout (default: right:50%)
|
||||
[up|down|left|right][,SIZE[%]]
|
||||
[,[no]wrap][,[no]cycle][,[no]follow][,[no]info]
|
||||
[up|down|left|right|next][,SIZE[%]]
|
||||
[,[no]wrap[-word]][,[no]cycle][,[no]follow][,[no]info]
|
||||
[,[no]hidden][,border-STYLE]
|
||||
[,+SCROLL[OFFSETS][/DENOM]][,~HEADER_LINES]
|
||||
[,default][,<SIZE_THRESHOLD(ALTERNATIVE_LAYOUT)]
|
||||
--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)
|
||||
--preview-label=LABEL
|
||||
--preview-label-pos=N Same as --border-label and --border-label-pos,
|
||||
but for preview window
|
||||
--preview-wrap-sign=STR Indicator for wrapped lines in the preview window
|
||||
|
||||
HEADER
|
||||
--header=STR String to print 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-border[=STYLE] Draw border around the header section
|
||||
[rounded|sharp|bold|block|thinblock|double|horizontal|vertical|
|
||||
top|bottom|left|right|line|none] (default: rounded)
|
||||
[rounded|sharp|bold|block|thinblock|double|dashed|horizontal|vertical|
|
||||
top|bottom|left|right|line|inline|none] (default: rounded)
|
||||
--header-lines-border[=STYLE]
|
||||
Display header from --header-lines with a separate 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-pos=COL Position of the header label
|
||||
[POSITIVE_INTEGER: columns from left|
|
||||
@@ -187,8 +192,8 @@ Usage: fzf [options]
|
||||
FOOTER
|
||||
--footer=STR String to print as footer
|
||||
--footer-border[=STYLE] Draw border around the footer section
|
||||
[rounded|sharp|bold|block|thinblock|double|horizontal|vertical|
|
||||
top|bottom|left|right|line|none] (default: line)
|
||||
[rounded|sharp|bold|block|thinblock|double|dashed|horizontal|vertical|
|
||||
top|bottom|left|right|line|inline|none] (default: line)
|
||||
--footer-label=LABEL Label to print on the footer border
|
||||
--footer-label-pos=COL Position of the footer label
|
||||
[POSITIVE_INTEGER: columns from left|
|
||||
@@ -227,6 +232,7 @@ Usage: fzf [options]
|
||||
--bash Print script to set up Bash shell integration
|
||||
--zsh Print script to set up Zsh shell integration
|
||||
--fish Print script to set up Fish shell integration
|
||||
--nushell Print script to set up Nushell integration
|
||||
|
||||
HELP
|
||||
--version Display version information and exit
|
||||
@@ -292,14 +298,32 @@ func defaultMargin() [4]sizeSpec {
|
||||
return [4]sizeSpec{}
|
||||
}
|
||||
|
||||
type trackOption int
|
||||
type trackOption struct {
|
||||
enabled bool
|
||||
index int32
|
||||
}
|
||||
|
||||
const (
|
||||
trackDisabled trackOption = iota
|
||||
trackEnabled
|
||||
trackCurrent
|
||||
var (
|
||||
trackDisabled = trackOption{false, minItem.Index()}
|
||||
trackEnabled = trackOption{true, minItem.Index()}
|
||||
)
|
||||
|
||||
func (t trackOption) Disabled() bool {
|
||||
return !t.enabled
|
||||
}
|
||||
|
||||
func (t trackOption) Global() bool {
|
||||
return t.enabled && t.index == minItem.Index()
|
||||
}
|
||||
|
||||
func (t trackOption) Current() bool {
|
||||
return t.enabled && t.index != minItem.Index()
|
||||
}
|
||||
|
||||
func trackCurrent(index int32) trackOption {
|
||||
return trackOption{true, index}
|
||||
}
|
||||
|
||||
type windowPosition int
|
||||
|
||||
const (
|
||||
@@ -308,6 +332,7 @@ const (
|
||||
posLeft
|
||||
posRight
|
||||
posCenter
|
||||
posNext // adjacent to the input section, on the list side
|
||||
)
|
||||
|
||||
type tmuxOptions struct {
|
||||
@@ -349,6 +374,7 @@ type previewOpts struct {
|
||||
scroll string
|
||||
hidden bool
|
||||
wrap bool
|
||||
wrapWord bool
|
||||
cycle bool
|
||||
follow bool
|
||||
info bool
|
||||
@@ -366,7 +392,7 @@ func (o *previewOpts) Toggle() {
|
||||
o.hidden = !o.hidden
|
||||
}
|
||||
|
||||
func (o *previewOpts) Border() tui.BorderShape {
|
||||
func (o *previewOpts) Border(layout layoutType) tui.BorderShape {
|
||||
shape := o.border
|
||||
if shape == tui.BorderLine {
|
||||
switch o.position {
|
||||
@@ -378,6 +404,12 @@ func (o *previewOpts) Border() tui.BorderShape {
|
||||
shape = tui.BorderRight
|
||||
case posRight:
|
||||
shape = tui.BorderLeft
|
||||
case posNext:
|
||||
if layout == layoutReverse {
|
||||
shape = tui.BorderBottom
|
||||
} else {
|
||||
shape = tui.BorderTop
|
||||
}
|
||||
}
|
||||
}
|
||||
return shape
|
||||
@@ -395,7 +427,7 @@ func parseTmuxOptions(arg string, index int) (*tmuxOptions, error) {
|
||||
var err error
|
||||
opts := defaultTmuxOptions(index)
|
||||
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 {
|
||||
return nil, errorToReturn
|
||||
}
|
||||
@@ -487,7 +519,7 @@ func parseLabelPosition(opts *labelOpts, arg string) error {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -525,7 +557,7 @@ func (o *previewOpts) compare(active *previewOpts, b *previewOpts) previewOptsCo
|
||||
return previewOptsDifferentLayout
|
||||
}
|
||||
|
||||
if a.wrap == b.wrap && a.headerLines == b.headerLines && a.info == b.info && a.scroll == b.scroll {
|
||||
if a.wrap == b.wrap && a.wrapWord == b.wrapWord && a.headerLines == b.headerLines && a.info == b.info && a.scroll == b.scroll {
|
||||
return previewOptsSame
|
||||
}
|
||||
|
||||
@@ -554,6 +586,7 @@ type Options struct {
|
||||
Bash bool
|
||||
Zsh bool
|
||||
Fish bool
|
||||
Nushell bool
|
||||
Man bool
|
||||
Fuzzy bool
|
||||
FuzzyAlgo algo.Algo
|
||||
@@ -567,11 +600,13 @@ type Options struct {
|
||||
FreezeLeft int
|
||||
FreezeRight int
|
||||
WithNth func(Delimiter) func([]Token, int32) string
|
||||
WithNthExpr string
|
||||
AcceptNth func(Delimiter) func([]Token, int32) string
|
||||
Delimiter Delimiter
|
||||
Sort int
|
||||
Raw bool
|
||||
Track trackOption
|
||||
IdNth []Range
|
||||
Tac bool
|
||||
Tail int
|
||||
Criteria []criterion
|
||||
@@ -587,7 +622,9 @@ type Options struct {
|
||||
Layout layoutType
|
||||
Cycle bool
|
||||
Wrap bool
|
||||
WrapWord bool
|
||||
WrapSign *string
|
||||
PreviewWrapSign *string
|
||||
MultiLine bool
|
||||
CursorLine bool
|
||||
KeepRight bool
|
||||
@@ -655,6 +692,8 @@ type Options struct {
|
||||
WalkerSkip []string
|
||||
Version bool
|
||||
Help bool
|
||||
Threads int
|
||||
Bench time.Duration
|
||||
CPUProfile string
|
||||
MEMProfile string
|
||||
BlockProfile string
|
||||
@@ -673,7 +712,13 @@ func filterNonEmpty(input []string) []string {
|
||||
}
|
||||
|
||||
func defaultPreviewOpts(command string) previewOpts {
|
||||
return previewOpts{command, posRight, sizeSpec{50, true}, "", false, false, false, false, true, defaultBorderShape, 0, 0, nil}
|
||||
return previewOpts{
|
||||
command: command,
|
||||
position: posRight,
|
||||
size: sizeSpec{50, true},
|
||||
info: true,
|
||||
border: defaultBorderShape,
|
||||
}
|
||||
}
|
||||
|
||||
func defaultOptions() *Options {
|
||||
@@ -689,6 +734,7 @@ func defaultOptions() *Options {
|
||||
Bash: false,
|
||||
Zsh: false,
|
||||
Fish: false,
|
||||
Nushell: false,
|
||||
Man: false,
|
||||
Fuzzy: true,
|
||||
FuzzyAlgo: algo.FuzzyMatchV2,
|
||||
@@ -715,6 +761,7 @@ func defaultOptions() *Options {
|
||||
Layout: layoutDefault,
|
||||
Cycle: false,
|
||||
Wrap: false,
|
||||
WrapWord: false,
|
||||
MultiLine: true,
|
||||
KeepRight: false,
|
||||
Hscroll: true,
|
||||
@@ -834,7 +881,7 @@ func nthTransformer(str string) (func(Delimiter) func([]Token, int32) string, er
|
||||
nth []Range
|
||||
}
|
||||
|
||||
parts := make([]NthParts, len(indexes))
|
||||
parts := make([]NthParts, 0, len(indexes))
|
||||
idx := 0
|
||||
for _, index := range indexes {
|
||||
if idx < index[0] {
|
||||
@@ -917,6 +964,8 @@ func parseBorder(str string, optional bool) (tui.BorderShape, error) {
|
||||
switch str {
|
||||
case "line":
|
||||
return tui.BorderLine, nil
|
||||
case "inline":
|
||||
return tui.BorderInline, nil
|
||||
case "rounded":
|
||||
return tui.BorderRounded, nil
|
||||
case "sharp":
|
||||
@@ -929,6 +978,8 @@ func parseBorder(str string, optional bool) (tui.BorderShape, error) {
|
||||
return tui.BorderThinBlock, nil
|
||||
case "double":
|
||||
return tui.BorderDouble, nil
|
||||
case "dashed":
|
||||
return tui.BorderDashed, nil
|
||||
case "horizontal":
|
||||
return tui.BorderHorizontal, nil
|
||||
case "vertical":
|
||||
@@ -947,7 +998,7 @@ func parseBorder(str string, optional bool) (tui.BorderShape, error) {
|
||||
if optional && str == "" {
|
||||
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) {
|
||||
@@ -1216,7 +1267,14 @@ func parseKeyChords(str string, message string) (map[tui.Event]string, []tui.Eve
|
||||
add(tui.F12)
|
||||
default:
|
||||
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])
|
||||
evt := tui.CtrlAltKey(r)
|
||||
if r == 'h' && !util.IsWindows() {
|
||||
@@ -1258,6 +1316,21 @@ func parseKeyChords(str string, message string) (map[tui.Event]string, []tui.Eve
|
||||
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) {
|
||||
str = strings.ToLower(str)
|
||||
switch str {
|
||||
@@ -1389,6 +1462,14 @@ func parseTheme(defaultTheme *tui.ColorTheme, str string) (*tui.ColorTheme, *tui
|
||||
cattr.Attr |= tui.Italic
|
||||
case "underline":
|
||||
cattr.Attr |= tui.Underline
|
||||
case "underline-double":
|
||||
cattr.Attr |= tui.Underline | tui.UlStyleDouble
|
||||
case "underline-curly":
|
||||
cattr.Attr |= tui.Underline | tui.UlStyleCurly
|
||||
case "underline-dotted":
|
||||
cattr.Attr |= tui.Underline | tui.UlStyleDotted
|
||||
case "underline-dashed":
|
||||
cattr.Attr |= tui.Underline | tui.UlStyleDashed
|
||||
case "blink":
|
||||
cattr.Attr |= tui.Blink
|
||||
case "reverse":
|
||||
@@ -1476,6 +1557,8 @@ func parseTheme(defaultTheme *tui.ColorTheme, str string) (*tui.ColorTheme, *tui
|
||||
mergeAttr(&theme.Nomatch)
|
||||
case "gutter":
|
||||
mergeAttr(&theme.Gutter)
|
||||
case "alt-gutter":
|
||||
mergeAttr(&theme.AltGutter)
|
||||
case "hl":
|
||||
mergeAttr(&theme.Match)
|
||||
case "current-hl", "hl+":
|
||||
@@ -1521,7 +1604,7 @@ func parseTheme(defaultTheme *tui.ColorTheme, str string) (*tui.ColorTheme, *tui
|
||||
case "info":
|
||||
mergeAttr(&theme.Info)
|
||||
case "pointer":
|
||||
mergeAttr(&theme.Cursor)
|
||||
mergeAttr(&theme.Pointer)
|
||||
case "marker":
|
||||
mergeAttr(&theme.Marker)
|
||||
case "header", "header-fg":
|
||||
@@ -1567,7 +1650,7 @@ func parseWalkerOpts(str string) (walkerOpts, error) {
|
||||
}
|
||||
|
||||
var (
|
||||
executeRegexp *regexp.Regexp
|
||||
argActionRegexp *regexp.Regexp
|
||||
splitRegexp *regexp.Regexp
|
||||
actionNameRegexp *regexp.Regexp
|
||||
)
|
||||
@@ -1586,8 +1669,8 @@ const (
|
||||
)
|
||||
|
||||
func init() {
|
||||
executeRegexp = 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)`)
|
||||
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-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("[,:]+")
|
||||
actionNameRegexp = regexp.MustCompile("(?i)^[a-z-]+")
|
||||
}
|
||||
@@ -1596,7 +1679,7 @@ func maskActionContents(action string) string {
|
||||
masked := ""
|
||||
Loop:
|
||||
for len(action) > 0 {
|
||||
loc := executeRegexp.FindStringIndex(action)
|
||||
loc := argActionRegexp.FindStringIndex(action)
|
||||
if loc == nil {
|
||||
masked += action
|
||||
break
|
||||
@@ -1650,10 +1733,10 @@ Loop:
|
||||
return masked
|
||||
}
|
||||
|
||||
func parseSingleActionList(str string) ([]*action, error) {
|
||||
// We prepend a colon to satisfy executeRegexp and remove it later
|
||||
func parseSingleActionList(str string, putAllowed bool) ([]*action, error) {
|
||||
// We prepend a colon to satisfy argActionRegexp and remove it later
|
||||
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) {
|
||||
@@ -1769,6 +1852,8 @@ func parseActionList(masked string, original string, prevActions []*action, putA
|
||||
appendAction(actToggleHeader)
|
||||
case "toggle-wrap":
|
||||
appendAction(actToggleWrap)
|
||||
case "toggle-wrap-word":
|
||||
appendAction(actToggleWrapWord)
|
||||
case "toggle-multi-line":
|
||||
appendAction(actToggleMultiLine)
|
||||
case "toggle-hscroll":
|
||||
@@ -1835,6 +1920,8 @@ func parseActionList(masked string, original string, prevActions []*action, putA
|
||||
appendAction(actTogglePreview)
|
||||
case "toggle-preview-wrap":
|
||||
appendAction(actTogglePreviewWrap)
|
||||
case "toggle-preview-wrap-word":
|
||||
appendAction(actTogglePreviewWrapWord)
|
||||
case "toggle-sort":
|
||||
appendAction(actToggleSort)
|
||||
case "offset-up":
|
||||
@@ -1955,8 +2042,7 @@ func parseKeymap(keymap map[tui.Event][]*action, str string) error {
|
||||
}
|
||||
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], putAllowed)
|
||||
keymap[key], err = parseActionList(pair[1], origPairStr[len(pair[0])+1:], keymap[key], key.Printable())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1994,6 +2080,8 @@ func isExecuteAction(str string) actionType {
|
||||
return actPreview
|
||||
case "change-header":
|
||||
return actChangeHeader
|
||||
case "change-header-lines":
|
||||
return actChangeHeaderLines
|
||||
case "change-footer":
|
||||
return actChangeFooter
|
||||
case "change-list-label":
|
||||
@@ -2024,6 +2112,8 @@ func isExecuteAction(str string) actionType {
|
||||
return actChangeMulti
|
||||
case "change-nth":
|
||||
return actChangeNth
|
||||
case "change-with-nth":
|
||||
return actChangeWithNth
|
||||
case "pos":
|
||||
return actPosition
|
||||
case "execute":
|
||||
@@ -2054,10 +2144,14 @@ func isExecuteAction(str string) actionType {
|
||||
return actTransformFooter
|
||||
case "transform-header":
|
||||
return actTransformHeader
|
||||
case "transform-header-lines":
|
||||
return actTransformHeaderLines
|
||||
case "transform-ghost":
|
||||
return actTransformGhost
|
||||
case "transform-nth":
|
||||
return actTransformNth
|
||||
case "transform-with-nth":
|
||||
return actTransformWithNth
|
||||
case "transform-pointer":
|
||||
return actTransformPointer
|
||||
case "transform-prompt":
|
||||
@@ -2084,10 +2178,14 @@ func isExecuteAction(str string) actionType {
|
||||
return actBgTransformFooter
|
||||
case "bg-transform-header":
|
||||
return actBgTransformHeader
|
||||
case "bg-transform-header-lines":
|
||||
return actBgTransformHeaderLines
|
||||
case "bg-transform-ghost":
|
||||
return actBgTransformGhost
|
||||
case "bg-transform-nth":
|
||||
return actBgTransformNth
|
||||
case "bg-transform-with-nth":
|
||||
return actBgTransformWithNth
|
||||
case "bg-transform-pointer":
|
||||
return actBgTransformPointer
|
||||
case "bg-transform-prompt":
|
||||
@@ -2160,9 +2258,6 @@ func parseHeight(str string, index int) (heightSpec, error) {
|
||||
str = str[1:]
|
||||
}
|
||||
if strings.HasPrefix(str, "-") {
|
||||
if heightSpec.auto {
|
||||
return heightSpec, errors.New("negative(-) height is not compatible with adaptive(~) height")
|
||||
}
|
||||
heightSpec.inverse = true
|
||||
str = str[1:]
|
||||
}
|
||||
@@ -2246,8 +2341,13 @@ func parsePreviewWindowImpl(opts *previewOpts, input string) error {
|
||||
opts.hidden = false
|
||||
case "wrap":
|
||||
opts.wrap = true
|
||||
opts.wrapWord = false
|
||||
case "wrap-word":
|
||||
opts.wrap = true
|
||||
opts.wrapWord = true
|
||||
case "nowrap":
|
||||
opts.wrap = false
|
||||
opts.wrapWord = false
|
||||
case "cycle":
|
||||
opts.cycle = true
|
||||
case "nocycle":
|
||||
@@ -2260,6 +2360,8 @@ func parsePreviewWindowImpl(opts *previewOpts, input string) error {
|
||||
opts.position = posLeft
|
||||
case "right":
|
||||
opts.position = posRight
|
||||
case "next":
|
||||
opts.position = posNext
|
||||
case "rounded", "border", "border-rounded":
|
||||
opts.border = tui.BorderRounded
|
||||
case "border-line":
|
||||
@@ -2274,6 +2376,8 @@ func parsePreviewWindowImpl(opts *previewOpts, input string) error {
|
||||
opts.border = tui.BorderThinBlock
|
||||
case "border-double":
|
||||
opts.border = tui.BorderDouble
|
||||
case "border-dashed":
|
||||
opts.border = tui.BorderDashed
|
||||
case "noborder", "border-none":
|
||||
opts.border = tui.BorderNone
|
||||
case "border-horizontal":
|
||||
@@ -2450,6 +2554,7 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
|
||||
opts.Bash = false
|
||||
opts.Zsh = false
|
||||
opts.Fish = false
|
||||
opts.Nushell = false
|
||||
opts.Help = false
|
||||
opts.Version = false
|
||||
opts.Man = false
|
||||
@@ -2562,6 +2667,9 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
|
||||
case "--fish":
|
||||
clearExitingOpts()
|
||||
opts.Fish = true
|
||||
case "--nushell":
|
||||
clearExitingOpts()
|
||||
opts.Nushell = true
|
||||
case "-h", "--help":
|
||||
clearExitingOpts()
|
||||
opts.Help = true
|
||||
@@ -2570,7 +2678,7 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
|
||||
opts.Version = true
|
||||
case "--no-winpty":
|
||||
opts.NoWinpty = true
|
||||
case "--tmux":
|
||||
case "--tmux", "--popup":
|
||||
given, str := optionalNextString()
|
||||
if given {
|
||||
if opts.Tmux, err = parseTmuxOptions(str, index); err != nil {
|
||||
@@ -2579,7 +2687,7 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
|
||||
} else {
|
||||
opts.Tmux = defaultTmuxOptions(index)
|
||||
}
|
||||
case "--no-tmux":
|
||||
case "--no-tmux", "--no-popup":
|
||||
opts.Tmux = nil
|
||||
case "--tty-default":
|
||||
if opts.TtyDefault, err = nextString("tty device name required"); err != nil {
|
||||
@@ -2724,6 +2832,7 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
|
||||
if opts.WithNth, err = nthTransformer(str); err != nil {
|
||||
return err
|
||||
}
|
||||
opts.WithNthExpr = str
|
||||
case "--accept-nth":
|
||||
str, err := nextString("nth expression required")
|
||||
if err != nil {
|
||||
@@ -2746,6 +2855,16 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
|
||||
opts.Track = trackEnabled
|
||||
case "--no-track":
|
||||
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":
|
||||
opts.Tac = true
|
||||
case "--no-tac":
|
||||
@@ -2811,9 +2930,29 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
|
||||
case "--no-cycle":
|
||||
opts.Cycle = false
|
||||
case "--wrap":
|
||||
opts.Wrap = true
|
||||
given, str := optionalNextString()
|
||||
if given {
|
||||
switch str {
|
||||
case "char":
|
||||
opts.Wrap = true
|
||||
opts.WrapWord = false
|
||||
case "word":
|
||||
opts.Wrap = true
|
||||
opts.WrapWord = true
|
||||
default:
|
||||
return errors.New("invalid wrap mode: " + str + " (expected: char or word)")
|
||||
}
|
||||
} else {
|
||||
opts.Wrap = true
|
||||
}
|
||||
case "--no-wrap":
|
||||
opts.Wrap = false
|
||||
opts.WrapWord = false
|
||||
case "--wrap-word":
|
||||
opts.Wrap = true
|
||||
opts.WrapWord = true
|
||||
case "--no-wrap-word":
|
||||
opts.WrapWord = false
|
||||
case "--wrap-sign":
|
||||
str, err := nextString("wrap sign required")
|
||||
if err != nil {
|
||||
@@ -3033,7 +3172,7 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
|
||||
case "--no-preview":
|
||||
opts.Preview.command = ""
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -3047,8 +3186,14 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
|
||||
if opts.Preview.border, err = parseBorder(arg, !hasArg); err != nil {
|
||||
return err
|
||||
}
|
||||
case "--preview-wrap-sign":
|
||||
str, err := nextString("preview wrap sign required")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.PreviewWrapSign = &str
|
||||
case "--height":
|
||||
str, err := nextString("height required: [~]HEIGHT[%]")
|
||||
str, err := nextString("height required: [~][-]HEIGHT[%]")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -3293,6 +3438,23 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
|
||||
return err
|
||||
}
|
||||
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":
|
||||
if opts.CPUProfile, err = nextString("file path required: cpu"); err != nil {
|
||||
return err
|
||||
@@ -3398,7 +3560,9 @@ func applyPreset(opts *Options, preset string) error {
|
||||
opts.Preview.border = tui.BorderLine
|
||||
opts.Preview.info = false
|
||||
opts.InfoStyle = infoDefault
|
||||
opts.Theme.Gutter = tui.ColorAttr{Color: -1, Attr: 0}
|
||||
opts.Theme.Gutter = tui.NewColorAttr()
|
||||
space := " "
|
||||
opts.Gutter = &space
|
||||
empty := ""
|
||||
opts.Separator = &empty
|
||||
opts.Scrollbar = &empty
|
||||
@@ -3473,7 +3637,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]} {
|
||||
if s.percent {
|
||||
return errors.New("adaptive height is not compatible with top/bottom percent margin")
|
||||
@@ -3490,6 +3654,19 @@ func validateOptions(opts *Options) error {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -3507,6 +3684,10 @@ func (opts *Options) useTmux() bool {
|
||||
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 {
|
||||
if opts.Inputless {
|
||||
return true
|
||||
|
||||
+93
-2
@@ -299,6 +299,39 @@ func TestBind(t *testing.T) {
|
||||
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) {
|
||||
var base *tui.ColorTheme
|
||||
theme := tui.Dark256
|
||||
@@ -448,6 +481,64 @@ func TestPreviewOpts(t *testing.T) {
|
||||
opts.Preview.size.size == 70) {
|
||||
t.Error(opts.Preview)
|
||||
}
|
||||
|
||||
// wrap-word tests
|
||||
opts = optsFor("--preview-window=wrap-word")
|
||||
if !(opts.Preview.wrap == true && opts.Preview.wrapWord == true) {
|
||||
t.Errorf("wrap-word: wrap=%v, wrapWord=%v", opts.Preview.wrap, opts.Preview.wrapWord)
|
||||
}
|
||||
opts = optsFor("--preview-window=wrap-word,nowrap")
|
||||
if !(opts.Preview.wrap == false && opts.Preview.wrapWord == false) {
|
||||
t.Errorf("wrap-word,nowrap: wrap=%v, wrapWord=%v", opts.Preview.wrap, opts.Preview.wrapWord)
|
||||
}
|
||||
opts = optsFor("--preview-window=wrap-word,wrap")
|
||||
if !(opts.Preview.wrap == true && opts.Preview.wrapWord == false) {
|
||||
t.Errorf("wrap-word,wrap: wrap=%v, wrapWord=%v", opts.Preview.wrap, opts.Preview.wrapWord)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewWrapSign(t *testing.T) {
|
||||
// Default: no preview wrap sign override
|
||||
opts := optsFor()
|
||||
if opts.PreviewWrapSign != nil {
|
||||
t.Errorf("expected nil PreviewWrapSign, got %v", *opts.PreviewWrapSign)
|
||||
}
|
||||
|
||||
// --preview-wrap-sign sets PreviewWrapSign
|
||||
opts = optsFor("--preview-wrap-sign", ">> ")
|
||||
if opts.PreviewWrapSign == nil || *opts.PreviewWrapSign != ">> " {
|
||||
t.Errorf("expected '>> ', got %v", opts.PreviewWrapSign)
|
||||
}
|
||||
|
||||
// --preview-wrap-sign is independent of --wrap-sign
|
||||
opts = optsFor("--wrap-sign", "| ", "--preview-wrap-sign", ">> ")
|
||||
if opts.WrapSign == nil || *opts.WrapSign != "| " {
|
||||
t.Errorf("expected WrapSign '| ', got %v", opts.WrapSign)
|
||||
}
|
||||
if opts.PreviewWrapSign == nil || *opts.PreviewWrapSign != ">> " {
|
||||
t.Errorf("expected PreviewWrapSign '>> ', got %v", opts.PreviewWrapSign)
|
||||
}
|
||||
|
||||
// --preview-wrap-sign without --wrap-sign
|
||||
opts = optsFor("--preview-wrap-sign", "→ ")
|
||||
if opts.WrapSign != nil {
|
||||
t.Errorf("expected nil WrapSign, got %v", *opts.WrapSign)
|
||||
}
|
||||
if opts.PreviewWrapSign == nil || *opts.PreviewWrapSign != "→ " {
|
||||
t.Errorf("expected PreviewWrapSign '→ ', got %v", opts.PreviewWrapSign)
|
||||
}
|
||||
|
||||
// Last --preview-wrap-sign wins
|
||||
opts = optsFor("--preview-wrap-sign", "A ", "--preview-wrap-sign", "B ")
|
||||
if opts.PreviewWrapSign == nil || *opts.PreviewWrapSign != "B " {
|
||||
t.Errorf("expected PreviewWrapSign 'B ', got %v", opts.PreviewWrapSign)
|
||||
}
|
||||
|
||||
// Empty string is allowed
|
||||
opts = optsFor("--preview-wrap-sign", "")
|
||||
if opts.PreviewWrapSign == nil || *opts.PreviewWrapSign != "" {
|
||||
t.Errorf("expected empty PreviewWrapSign, got %v", opts.PreviewWrapSign)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdditiveExpect(t *testing.T) {
|
||||
@@ -481,7 +572,7 @@ func TestValidateSign(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 {
|
||||
t.Errorf("Invalid number of actions parsed:%d", len(actions))
|
||||
}
|
||||
@@ -497,7 +588,7 @@ func TestParseSingleActionList(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestParseSingleActionListError(t *testing.T) {
|
||||
_, err := parseSingleActionList("change-query(foobar)baz")
|
||||
_, err := parseSingleActionList("change-query(foobar)baz", false)
|
||||
if err == nil {
|
||||
t.Errorf("Failed to detect error")
|
||||
}
|
||||
|
||||
+91
-50
@@ -61,9 +61,12 @@ type Pattern struct {
|
||||
delimiter Delimiter
|
||||
nth []Range
|
||||
revision revision
|
||||
procFun map[termType]algo.Algo
|
||||
procFun [6]algo.Algo
|
||||
cache *ChunkCache
|
||||
denylist map[int32]struct{}
|
||||
startIndex int32
|
||||
directAlgo algo.Algo
|
||||
directTerm *term
|
||||
}
|
||||
|
||||
var _splitRegex *regexp.Regexp
|
||||
@@ -74,7 +77,7 @@ func init() {
|
||||
|
||||
// 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,
|
||||
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
|
||||
if extended {
|
||||
@@ -146,9 +149,11 @@ func BuildPattern(cache *ChunkCache, patternCache map[string]*Pattern, fuzzy boo
|
||||
delimiter: delimiter,
|
||||
cache: cache,
|
||||
denylist: denylist,
|
||||
procFun: make(map[termType]algo.Algo)}
|
||||
startIndex: startIndex,
|
||||
}
|
||||
|
||||
ptr.cacheKey = ptr.buildCacheKey()
|
||||
ptr.directAlgo, ptr.directTerm = ptr.buildDirectAlgo(fuzzyAlgo)
|
||||
ptr.procFun[termFuzzy] = fuzzyAlgo
|
||||
ptr.procFun[termEqual] = algo.EqualMatch
|
||||
ptr.procFun[termExact] = algo.ExactMatchNaive
|
||||
@@ -272,6 +277,22 @@ func (p *Pattern) buildCacheKey() string {
|
||||
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
|
||||
func (p *Pattern) CacheKey() string {
|
||||
return p.cacheKey
|
||||
@@ -279,84 +300,104 @@ func (p *Pattern) CacheKey() string {
|
||||
|
||||
// Match returns the list of matches Items in the given Chunk
|
||||
func (p *Pattern) Match(chunk *Chunk, slab *util.Slab) []Result {
|
||||
// ChunkCache: Exact match
|
||||
cacheKey := p.CacheKey()
|
||||
|
||||
// Bitmap cache: exact match or prefix/suffix
|
||||
var cachedBitmap *ChunkBitmap
|
||||
if p.cacheable {
|
||||
if cached := p.cache.Lookup(chunk, cacheKey); cached != nil {
|
||||
return cached
|
||||
}
|
||||
cachedBitmap = p.cache.Lookup(chunk, cacheKey)
|
||||
}
|
||||
if cachedBitmap == nil {
|
||||
cachedBitmap = p.cache.Search(chunk, cacheKey)
|
||||
}
|
||||
|
||||
// Prefix/suffix cache
|
||||
space := p.cache.Search(chunk, cacheKey)
|
||||
|
||||
matches := p.matchChunk(chunk, space, slab)
|
||||
matches, bitmap := p.matchChunk(chunk, cachedBitmap, slab)
|
||||
|
||||
if p.cacheable {
|
||||
p.cache.Add(chunk, cacheKey, matches)
|
||||
p.cache.Add(chunk, cacheKey, bitmap, len(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{}
|
||||
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 {
|
||||
// Huge code duplication for minimizing unnecessary map lookups
|
||||
if space == nil {
|
||||
for idx := 0; idx < chunk.count; idx++ {
|
||||
if match, _, _ := p.MatchItem(&chunk.items[idx], p.withPos, slab); match != nil {
|
||||
matches = append(matches, *match)
|
||||
}
|
||||
for idx := startIdx; idx < chunk.count; idx++ {
|
||||
if hasCachedBitmap && cachedBitmap[idx/64]&(uint64(1)<<(idx%64)) == 0 {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
for _, result := range space {
|
||||
if match, _, _ := p.MatchItem(result.item, 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)
|
||||
}
|
||||
}
|
||||
return matches
|
||||
return matches, bitmap
|
||||
}
|
||||
|
||||
if space == nil {
|
||||
for idx := 0; idx < chunk.count; idx++ {
|
||||
if _, prs := p.denylist[chunk.items[idx].Index()]; prs {
|
||||
continue
|
||||
}
|
||||
if match, _, _ := p.MatchItem(&chunk.items[idx], p.withPos, slab); match != nil {
|
||||
matches = append(matches, *match)
|
||||
}
|
||||
for idx := startIdx; idx < chunk.count; idx++ {
|
||||
if hasCachedBitmap && cachedBitmap[idx/64]&(uint64(1)<<(idx%64)) == 0 {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
if _, prs := p.denylist[chunk.items[idx].Index()]; prs {
|
||||
continue
|
||||
}
|
||||
if match, _, _ := p.MatchItem(&chunk.items[idx], p.withPos, slab); match.item != nil {
|
||||
bitmap[idx/64] |= uint64(1) << (idx % 64)
|
||||
matches = append(matches, match)
|
||||
}
|
||||
}
|
||||
return matches
|
||||
return matches, bitmap
|
||||
}
|
||||
|
||||
// MatchItem returns true if the Item is a match
|
||||
func (p *Pattern) MatchItem(item *Item, withPos bool, slab *util.Slab) (*Result, []Offset, *[]int) {
|
||||
// MatchItem returns the match result if the Item is a match.
|
||||
// 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 offsets, bonus, pos := p.extendedMatch(item, withPos, slab); len(offsets) == len(p.termSets) {
|
||||
result := buildResult(item, offsets, bonus)
|
||||
return &result, offsets, pos
|
||||
return buildResult(item, offsets, bonus), offsets, pos
|
||||
}
|
||||
return nil, nil, nil
|
||||
return Result{}, nil, nil
|
||||
}
|
||||
offset, bonus, pos := p.basicMatch(item, withPos, slab)
|
||||
if sidx := offset[0]; sidx >= 0 {
|
||||
offsets := []Offset{offset}
|
||||
result := buildResult(item, offsets, bonus)
|
||||
return &result, offsets, pos
|
||||
return buildResult(item, offsets, bonus), offsets, pos
|
||||
}
|
||||
return nil, nil, nil
|
||||
return Result{}, nil, nil
|
||||
}
|
||||
|
||||
func (p *Pattern) basicMatch(item *Item, withPos bool, slab *util.Slab) (Offset, int, *[]int) {
|
||||
|
||||
+119
-2
@@ -2,6 +2,7 @@ package fzf
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"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 {
|
||||
return BuildPattern(NewChunkCache(), make(map[string]*Pattern),
|
||||
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) {
|
||||
@@ -137,7 +138,7 @@ func TestOrigTextAndTransformed(t *testing.T) {
|
||||
origText: &origBytes,
|
||||
transformed: &transformed{pattern.revision, trans}}
|
||||
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" &&
|
||||
string(*matches[0].item.origText) == "junegunn.choi" &&
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -23,6 +23,32 @@ func escapeSingleQuote(str string) string {
|
||||
return "'" + strings.ReplaceAll(str, "'", "'\\''") + "'"
|
||||
}
|
||||
|
||||
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) {
|
||||
ns := time.Now().UnixNano()
|
||||
output := filepath.Join(os.TempDir(), fmt.Sprintf("fzf-%s-%d", name, ns))
|
||||
|
||||
+43
-3
@@ -178,7 +178,7 @@ func (r *Reader) feed(src io.Reader) {
|
||||
var err error
|
||||
for {
|
||||
n := 0
|
||||
scope := slab[:util.Min(len(slab), readerBufferSize)]
|
||||
scope := slab[:min(len(slab), readerBufferSize)]
|
||||
for range 100 {
|
||||
n, err = src.Read(scope)
|
||||
if n > 0 || err != nil {
|
||||
@@ -274,6 +274,24 @@ func (r *Reader) readFiles(roots []string, opts walkerOpts, ignores []string) bo
|
||||
ToSlash: fastwalk.DefaultToSlash(),
|
||||
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{}
|
||||
ignoresFull := []string{}
|
||||
ignoresSuffix := []string{}
|
||||
@@ -303,8 +321,30 @@ func (r *Reader) readFiles(roots []string, opts walkerOpts, ignores []string) bo
|
||||
}
|
||||
path = trimPath(path)
|
||||
if path != "." {
|
||||
isDir := de.IsDir()
|
||||
if isDir || opts.follow && isSymlinkToDir(path, de) {
|
||||
isDirSymlink := isSymlinkToDir(path, de)
|
||||
if isDirSymlink && !opts.follow {
|
||||
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
|
||||
if isDir {
|
||||
base := filepath.Base(path)
|
||||
if !opts.hidden && base[0] == '.' && base != ".." {
|
||||
return filepath.SkipDir
|
||||
|
||||
+118
-30
@@ -2,6 +2,7 @@ package fzf
|
||||
|
||||
import (
|
||||
"math"
|
||||
"slices"
|
||||
"sort"
|
||||
"unicode"
|
||||
|
||||
@@ -30,11 +31,9 @@ type Result struct {
|
||||
|
||||
func buildResult(item *Item, offsets []Offset, score int) Result {
|
||||
if len(offsets) > 1 {
|
||||
sort.Sort(ByOrder(offsets))
|
||||
slices.SortFunc(offsets, compareOffsets)
|
||||
}
|
||||
|
||||
result := Result{item: item}
|
||||
numChars := item.text.Length()
|
||||
minBegin := math.MaxUint16
|
||||
minEnd := math.MaxUint16
|
||||
maxEnd := 0
|
||||
@@ -42,13 +41,21 @@ func buildResult(item *Item, offsets []Offset, score int) Result {
|
||||
for _, offset := range offsets {
|
||||
b, e := int(offset[0]), int(offset[1])
|
||||
if b < e {
|
||||
minBegin = util.Min(b, minBegin)
|
||||
minEnd = util.Min(e, minEnd)
|
||||
maxEnd = util.Max(e, maxEnd)
|
||||
minBegin = min(b, minBegin)
|
||||
minEnd = min(e, minEnd)
|
||||
maxEnd = max(e, maxEnd)
|
||||
validOffsetFound = true
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
val := uint16(math.MaxUint16)
|
||||
switch criterion {
|
||||
@@ -75,7 +82,6 @@ func buildResult(item *Item, offsets []Offset, score int) Result {
|
||||
val = item.TrimLength()
|
||||
case byPathname:
|
||||
if validOffsetFound {
|
||||
// lastDelim := strings.LastIndexByte(item.text.ToString(), '/')
|
||||
lastDelim := -1
|
||||
s := item.text.ToString()
|
||||
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}}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
// 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
|
||||
// ------------ ---- -- ----
|
||||
@@ -192,7 +198,10 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
|
||||
start := 0
|
||||
ansiToColorPair := func(ansi ansiOffset, base tui.ColorPair) tui.ColorPair {
|
||||
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
|
||||
if base.ShouldStripColors() {
|
||||
@@ -206,8 +215,12 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
|
||||
if bg == -1 {
|
||||
bg = colBase.Bg()
|
||||
}
|
||||
return tui.NewColorPair(fg, bg, ansi.color.attr).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
|
||||
add := func(idx int) {
|
||||
if curr.fbg >= 0 {
|
||||
@@ -221,7 +234,7 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
|
||||
if curr.match {
|
||||
var color tui.ColorPair
|
||||
if curr.nth {
|
||||
color = colBase.WithAttr(attrNth).Merge(colMatch)
|
||||
color = nthBase.Merge(colMatch)
|
||||
} else {
|
||||
color = colBase.Merge(colMatch)
|
||||
}
|
||||
@@ -241,7 +254,7 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
|
||||
if color.Fg().IsDefault() && origColor.HasBg() {
|
||||
color = origColor
|
||||
if curr.nth {
|
||||
color = color.WithAttr(attrNth &^ tui.AttrRegular)
|
||||
color = color.WithAttr((attrNth &^ tui.AttrRegular).Merge(nthOverlay))
|
||||
}
|
||||
} else {
|
||||
color = origColor.MergeNonDefault(color)
|
||||
@@ -253,7 +266,7 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
|
||||
ansi := itemColors[curr.index]
|
||||
base := colBase
|
||||
if curr.nth {
|
||||
base = base.WithAttr(attrNth)
|
||||
base = nthBase
|
||||
}
|
||||
if hidden {
|
||||
base = base.WithFg(theme.Nomatch)
|
||||
@@ -265,7 +278,7 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
|
||||
match: false,
|
||||
url: ansi.color.url})
|
||||
} else {
|
||||
color := colBase.WithAttr(attrNth)
|
||||
color := nthBase
|
||||
if hidden {
|
||||
color = color.WithFg(theme.Nomatch)
|
||||
}
|
||||
@@ -288,21 +301,20 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
|
||||
return colors
|
||||
}
|
||||
|
||||
// ByOrder is for sorting substring offsets
|
||||
type ByOrder []Offset
|
||||
|
||||
func (a ByOrder) Len() int {
|
||||
return len(a)
|
||||
}
|
||||
|
||||
func (a ByOrder) Swap(i, j int) {
|
||||
a[i], a[j] = a[j], a[i]
|
||||
}
|
||||
|
||||
func (a ByOrder) Less(i, j int) bool {
|
||||
ioff := a[i]
|
||||
joff := a[j]
|
||||
return (ioff[0] < joff[0]) || (ioff[0] == joff[0]) && (ioff[1] <= joff[1])
|
||||
func compareOffsets(a, b Offset) int {
|
||||
if a[0] < b[0] {
|
||||
return -1
|
||||
}
|
||||
if a[0] > b[0] {
|
||||
return 1
|
||||
}
|
||||
if a[1] < b[1] {
|
||||
return -1
|
||||
}
|
||||
if a[1] > b[1] {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ByRelevance is for sorting Items
|
||||
@@ -334,3 +346,79 @@ func (a ByRelevanceTac) Swap(i, j int) {
|
||||
func (a ByRelevanceTac) Less(i, j int) bool {
|
||||
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
|
||||
|
||||
for pass := range 8 {
|
||||
shift := uint(pass) * 8
|
||||
|
||||
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
|
||||
|
||||
@@ -14,3 +14,7 @@ func compareRanks(irank Result, jrank Result, tac bool) bool {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
+97
-7
@@ -2,6 +2,8 @@ package fzf
|
||||
|
||||
import (
|
||||
"math"
|
||||
"math/rand"
|
||||
"slices"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
@@ -18,7 +20,7 @@ func TestOffsetSort(t *testing.T) {
|
||||
offsets := []Offset{
|
||||
{3, 5}, {2, 7},
|
||||
{1, 3}, {2, 9}}
|
||||
sort.Sort(ByOrder(offsets))
|
||||
slices.SortFunc(offsets, compareOffsets)
|
||||
|
||||
if offsets[0][0] != 1 || offsets[0][1] != 3 ||
|
||||
offsets[1][0] != 2 || offsets[1][1] != 7 ||
|
||||
@@ -124,14 +126,14 @@ func TestColorOffset(t *testing.T) {
|
||||
item := Result{
|
||||
item: &Item{
|
||||
colors: &[]ansiOffset{
|
||||
{[2]int32{0, 20}, ansiState{1, 5, 0, -1, nil}},
|
||||
{[2]int32{22, 27}, ansiState{2, 6, tui.Bold, -1, nil}},
|
||||
{[2]int32{30, 32}, ansiState{3, 7, 0, -1, nil}},
|
||||
{[2]int32{33, 40}, ansiState{4, 8, tui.Bold, -1, nil}}}}}
|
||||
{[2]int32{0, 20}, ansiState{1, 5, -1, 0, -1, nil}},
|
||||
{[2]int32{22, 27}, ansiState{2, 6, -1, tui.Bold, -1, nil}},
|
||||
{[2]int32{30, 32}, ansiState{3, 7, -1, 0, -1, nil}},
|
||||
{[2]int32{33, 40}, ansiState{4, 8, -1, tui.Bold, -1, nil}}}}}
|
||||
|
||||
colBase := tui.NewColorPair(89, 189, 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) {
|
||||
o := colors[idx]
|
||||
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}}
|
||||
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}}
|
||||
// {[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(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
|
||||
|
||||
@@ -14,3 +14,7 @@ func compareRanks(irank Result, jrank Result, tac bool) bool {
|
||||
}
|
||||
return (irank.item.Index() <= jrank.item.Index()) != tac
|
||||
}
|
||||
|
||||
func sortKey(r *Result) uint64 {
|
||||
return *(*uint64)(unsafe.Pointer(&r.points[0]))
|
||||
}
|
||||
|
||||
+29
-22
@@ -122,13 +122,12 @@ func startHttpServer(address listenAddress, actionChannel chan []*action, getHan
|
||||
}
|
||||
}
|
||||
|
||||
server := httpServer{
|
||||
apiKey: []byte(apiKey),
|
||||
actionChannel: actionChannel,
|
||||
getHandler: getHandler,
|
||||
}
|
||||
|
||||
go func() {
|
||||
server := httpServer{
|
||||
apiKey: []byte(apiKey),
|
||||
actionChannel: actionChannel,
|
||||
getHandler: getHandler,
|
||||
}
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
@@ -154,7 +153,7 @@ func startHttpServer(address listenAddress, actionChannel chan []*action, getHan
|
||||
func (server *httpServer) handleHttpRequest(conn net.Conn) string {
|
||||
contentLength := 0
|
||||
apiKey := ""
|
||||
body := ""
|
||||
var bodyBuilder strings.Builder
|
||||
answer := func(code string, message string) string {
|
||||
message += "\n"
|
||||
return code + fmt.Sprintf("Content-Length: %d%s", len(message), crlf+crlf+message)
|
||||
@@ -176,30 +175,29 @@ func (server *httpServer) handleHttpRequest(conn net.Conn) string {
|
||||
token := data[:found+len(crlf)]
|
||||
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, nil, nil
|
||||
})
|
||||
|
||||
section := 0
|
||||
var getMatch []string
|
||||
Loop:
|
||||
for scanner.Scan() {
|
||||
text := scanner.Text()
|
||||
switch section {
|
||||
case 0:
|
||||
getMatch := getRegex.FindStringSubmatch(text)
|
||||
if len(getMatch) > 0 {
|
||||
response := server.getHandler(parseGetParams(getMatch[1]))
|
||||
if len(response) > 0 {
|
||||
return good(response)
|
||||
}
|
||||
return answer(httpUnavailable+jsonContentType, `{"error":"timeout"}`)
|
||||
} else if !strings.HasPrefix(text, "POST / HTTP") {
|
||||
case 0: // Request line
|
||||
getMatch = getRegex.FindStringSubmatch(text)
|
||||
if len(getMatch) == 0 && !strings.HasPrefix(text, "POST / HTTP") {
|
||||
return bad("invalid request method")
|
||||
}
|
||||
section++
|
||||
case 1:
|
||||
if text == crlf {
|
||||
case 1: // Request headers
|
||||
if text == crlf { // End of headers
|
||||
if len(getMatch) > 0 {
|
||||
break Loop
|
||||
}
|
||||
if contentLength == 0 {
|
||||
return bad("content-length header missing")
|
||||
}
|
||||
@@ -219,8 +217,8 @@ func (server *httpServer) handleHttpRequest(conn net.Conn) string {
|
||||
apiKey = strings.TrimSpace(pair[1])
|
||||
}
|
||||
}
|
||||
case 2:
|
||||
body += text
|
||||
case 2: // Request body
|
||||
bodyBuilder.WriteString(text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,12 +226,21 @@ func (server *httpServer) handleHttpRequest(conn net.Conn) string {
|
||||
return unauthorized("invalid api key")
|
||||
}
|
||||
|
||||
if len(getMatch) > 0 {
|
||||
response := server.getHandler(parseGetParams(getMatch[1]))
|
||||
if len(response) > 0 {
|
||||
return good(response)
|
||||
}
|
||||
return answer(httpUnavailable+jsonContentType, `{"error":"timeout"}`)
|
||||
}
|
||||
|
||||
body := bodyBuilder.String()
|
||||
if len(body) < contentLength {
|
||||
return bad("incomplete request")
|
||||
}
|
||||
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 {
|
||||
return bad(err.Error())
|
||||
}
|
||||
|
||||
+1606
-617
File diff suppressed because it is too large
Load Diff
@@ -699,3 +699,72 @@ func readFile(path string) ([]byte, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWordWrapAnsiLine(t *testing.T) {
|
||||
term := &Terminal{}
|
||||
|
||||
// Simple wrapping
|
||||
result := term.wordWrapAnsiLine("hello world", 7, 2)
|
||||
if len(result) != 2 || result[0] != "hello" || result[1] != "world" {
|
||||
t.Errorf("Simple: %q", result)
|
||||
}
|
||||
|
||||
// No wrapping needed
|
||||
result = term.wordWrapAnsiLine("hello", 10, 2)
|
||||
if len(result) != 1 || result[0] != "hello" {
|
||||
t.Errorf("No wrap: %q", result)
|
||||
}
|
||||
|
||||
// ANSI codes preserved across split
|
||||
result = term.wordWrapAnsiLine("\x1b[31mhello \x1b[32mworld", 8, 2)
|
||||
if len(result) != 2 || result[0] != "\x1b[31mhello" || result[1] != "\x1b[32mworld" {
|
||||
t.Errorf("ANSI: %q", result)
|
||||
}
|
||||
|
||||
// Long word (no space) - no break, let character wrapping handle it
|
||||
result = term.wordWrapAnsiLine("abcdefghij", 5, 2)
|
||||
if len(result) != 1 || result[0] != "abcdefghij" {
|
||||
t.Errorf("Long word: %q", result)
|
||||
}
|
||||
|
||||
// Multiple words with continuation wrapSignWidth
|
||||
result = term.wordWrapAnsiLine("aa bb cc dd", 5, 2)
|
||||
// max=5 for first line, max=3 for continuations (5-2)
|
||||
// "aa bb" (5 wide), split at second space -> "aa bb" | "cc" | "dd"
|
||||
if len(result) != 3 || result[0] != "aa bb" || result[1] != "cc" || result[2] != "dd" {
|
||||
t.Errorf("Multiple words: %q", result)
|
||||
}
|
||||
|
||||
// Empty string
|
||||
result = term.wordWrapAnsiLine("", 10, 2)
|
||||
if len(result) != 1 || result[0] != "" {
|
||||
t.Errorf("Empty: %q", result)
|
||||
}
|
||||
|
||||
// OSC 8 hyperlink preserved
|
||||
result = term.wordWrapAnsiLine("\x1b]8;;http://example.com\x1b\\click here\x1b]8;;\x1b\\", 8, 2)
|
||||
if len(result) != 2 {
|
||||
t.Errorf("Hyperlink split count: %d, %q", len(result), result)
|
||||
}
|
||||
|
||||
// Tab handling: tab expands to tabstop-aligned width
|
||||
term.tabstop = 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
|
||||
result = term.wordWrapAnsiLine("\thi there", 15, 2)
|
||||
if len(result) != 2 || result[0] != "\thi" || result[1] != "there" {
|
||||
t.Errorf("Tab: %q", result)
|
||||
}
|
||||
|
||||
// Tab as word boundary: "hello"(5) + tab(3→col8) + "world"(5) = 13 total
|
||||
// maxWidth=13: fits without wrapping
|
||||
result = term.wordWrapAnsiLine("hello\tworld", 13, 2)
|
||||
if len(result) != 1 || result[0] != "hello\tworld" {
|
||||
t.Errorf("Tab no wrap: %q", result)
|
||||
}
|
||||
// maxWidth=12: 13 > 12, wraps at tab
|
||||
result = term.wordWrapAnsiLine("hello\tworld", 12, 2)
|
||||
if len(result) != 2 || result[0] != "hello" || result[1] != "world" {
|
||||
t.Errorf("Tab wrap: %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-29
@@ -1,39 +1,11 @@
|
||||
package fzf
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"github.com/junegunn/fzf/src/tui"
|
||||
)
|
||||
|
||||
func runTmux(args []string, opts *Options) (int, error) {
|
||||
// Prepare arguments
|
||||
fzf, rest := args[0], args[1:]
|
||||
args = []string{"--bind=ctrl-z:ignore"}
|
||||
if !opts.Tmux.border && (opts.BorderShape == tui.BorderUndefined || opts.BorderShape == tui.BorderLine) {
|
||||
// We append --border option at the end, because `--style=full:STYLE`
|
||||
// may have changed the default border style.
|
||||
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-tmux --no-height`
|
||||
|
||||
// Get current directory
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
dir = "."
|
||||
}
|
||||
argStr, dir := popupArgStr(args, opts)
|
||||
|
||||
// Set tmux options for popup placement
|
||||
// C Both The centre of the terminal
|
||||
|
||||
+7
-5
@@ -161,7 +161,7 @@ func awkTokenizer(input string) ([]string, int) {
|
||||
end := 0
|
||||
for idx := 0; idx < len(input); idx++ {
|
||||
r := input[idx]
|
||||
white := r == 9 || r == 32
|
||||
white := r == 9 || r == 32 || r == 10
|
||||
switch state {
|
||||
case awkNil:
|
||||
if white {
|
||||
@@ -218,11 +218,12 @@ func Tokenize(text string, delimiter Delimiter) []Token {
|
||||
return withPrefixLengths(tokens, 0)
|
||||
}
|
||||
|
||||
// StripLastDelimiter removes the trailing delimiter and whitespaces
|
||||
// StripLastDelimiter removes the trailing delimiter
|
||||
func StripLastDelimiter(str string, delimiter Delimiter) string {
|
||||
if delimiter.str != nil {
|
||||
str = strings.TrimSuffix(str, *delimiter.str)
|
||||
} else if delimiter.regex != nil {
|
||||
return strings.TrimSuffix(str, *delimiter.str)
|
||||
}
|
||||
if delimiter.regex != nil {
|
||||
locs := delimiter.regex.FindAllStringIndex(str, -1)
|
||||
if len(locs) > 0 {
|
||||
lastLoc := locs[len(locs)-1]
|
||||
@@ -230,6 +231,7 @@ func StripLastDelimiter(str string, delimiter Delimiter) string {
|
||||
str = str[:lastLoc[0]]
|
||||
}
|
||||
}
|
||||
return str
|
||||
}
|
||||
return strings.TrimRightFunc(str, unicode.IsSpace)
|
||||
}
|
||||
@@ -302,7 +304,7 @@ func Transform(tokens []Token, withNth []Range) []Token {
|
||||
end += numTokens + 1
|
||||
}
|
||||
}
|
||||
minIdx = util.Max(0, begin-1)
|
||||
minIdx = max(0, begin-1)
|
||||
for idx := begin; idx <= end; idx++ {
|
||||
if idx >= 1 && idx <= numTokens {
|
||||
parts = append(parts, tokens[idx-1].text)
|
||||
|
||||
@@ -56,9 +56,9 @@ func TestParseRange(t *testing.T) {
|
||||
|
||||
func TestTokenize(t *testing.T) {
|
||||
// AWK-style
|
||||
input := " abc: def: ghi "
|
||||
input := " abc: \n\t def: ghi "
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -71,9 +71,9 @@ func TestTokenize(t *testing.T) {
|
||||
// With delimiter regex
|
||||
tokens = Tokenize(input, delimiterRegexp("\\s+"))
|
||||
if tokens[0].text.ToString() != " " || tokens[0].prefixLength != 0 ||
|
||||
tokens[1].text.ToString() != "abc: " || tokens[1].prefixLength != 2 ||
|
||||
tokens[2].text.ToString() != "def: " || tokens[2].prefixLength != 8 ||
|
||||
tokens[3].text.ToString() != "ghi " || tokens[3].prefixLength != 14 {
|
||||
tokens[1].text.ToString() != "abc: \n\t " || tokens[1].prefixLength != 2 ||
|
||||
tokens[2].text.ToString() != "def: " || tokens[2].prefixLength != 10 ||
|
||||
tokens[3].text.ToString() != "ghi " || tokens[3].prefixLength != 16 {
|
||||
t.Errorf("%s", tokens)
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -34,11 +34,11 @@ func (r *FullscreenRenderer) ShowCursor() {}
|
||||
func (r *FullscreenRenderer) Refresh() {}
|
||||
func (r *FullscreenRenderer) Close() {}
|
||||
func (r *FullscreenRenderer) Size() TermSize { return TermSize{} }
|
||||
|
||||
func (r *FullscreenRenderer) GetChar() Event { return Event{} }
|
||||
func (r *FullscreenRenderer) Top() int { return 0 }
|
||||
func (r *FullscreenRenderer) MaxX() int { return 0 }
|
||||
func (r *FullscreenRenderer) MaxY() int { return 0 }
|
||||
func (r *FullscreenRenderer) Top() int { return 0 }
|
||||
func (r *FullscreenRenderer) MaxX() int { return 0 }
|
||||
func (r *FullscreenRenderer) MaxY() int { return 0 }
|
||||
func (r *FullscreenRenderer) GetChar(bool) Event { return Event{} }
|
||||
func (r *FullscreenRenderer) CancelGetChar() {}
|
||||
|
||||
func (r *FullscreenRenderer) RefreshWindows(windows []Window) {}
|
||||
|
||||
|
||||
+19
-18
@@ -133,22 +133,22 @@ func _() {
|
||||
_ = x[CtrlAltShiftDelete-122]
|
||||
_ = x[CtrlAltShiftPageUp-123]
|
||||
_ = x[CtrlAltShiftPageDown-124]
|
||||
_ = x[Invalid-125]
|
||||
_ = x[Fatal-126]
|
||||
_ = x[BracketedPasteBegin-127]
|
||||
_ = x[BracketedPasteEnd-128]
|
||||
_ = x[Mouse-129]
|
||||
_ = x[DoubleClick-130]
|
||||
_ = x[LeftClick-131]
|
||||
_ = x[RightClick-132]
|
||||
_ = x[SLeftClick-133]
|
||||
_ = x[SRightClick-134]
|
||||
_ = x[ScrollUp-135]
|
||||
_ = x[ScrollDown-136]
|
||||
_ = x[SScrollUp-137]
|
||||
_ = x[SScrollDown-138]
|
||||
_ = x[PreviewScrollUp-139]
|
||||
_ = x[PreviewScrollDown-140]
|
||||
_ = x[Mouse-125]
|
||||
_ = x[DoubleClick-126]
|
||||
_ = x[LeftClick-127]
|
||||
_ = x[RightClick-128]
|
||||
_ = x[SLeftClick-129]
|
||||
_ = x[SRightClick-130]
|
||||
_ = x[ScrollUp-131]
|
||||
_ = x[ScrollDown-132]
|
||||
_ = x[SScrollUp-133]
|
||||
_ = x[SScrollDown-134]
|
||||
_ = x[PreviewScrollUp-135]
|
||||
_ = x[PreviewScrollDown-136]
|
||||
_ = x[Invalid-137]
|
||||
_ = x[Fatal-138]
|
||||
_ = x[BracketedPasteBegin-139]
|
||||
_ = x[BracketedPasteEnd-140]
|
||||
_ = x[Resize-141]
|
||||
_ = x[Change-142]
|
||||
_ = x[BackwardEOF-143]
|
||||
@@ -163,11 +163,12 @@ func _() {
|
||||
_ = x[ClickHeader-152]
|
||||
_ = x[ClickFooter-153]
|
||||
_ = x[Multi-154]
|
||||
_ = x[Every-155]
|
||||
}
|
||||
|
||||
const _EventType_name = "RuneCtrlACtrlBCtrlCCtrlDCtrlECtrlFCtrlGCtrlHTabCtrlJCtrlKCtrlLEnterCtrlNCtrlOCtrlPCtrlQCtrlRCtrlSCtrlTCtrlUCtrlVCtrlWCtrlXCtrlYCtrlZEscCtrlSpaceCtrlBackSlashCtrlRightBracketCtrlCaretCtrlSlashShiftTabBackspaceDeletePageUpPageDownUpDownLeftRightHomeEndInsertShiftUpShiftDownShiftLeftShiftRightShiftDeleteShiftHomeShiftEndShiftPageUpShiftPageDownF1F2F3F4F5F6F7F8F9F10F11F12AltBackspaceAltUpAltDownAltLeftAltRightAltDeleteAltHomeAltEndAltPageUpAltPageDownAltShiftUpAltShiftDownAltShiftLeftAltShiftRightAltShiftDeleteAltShiftHomeAltShiftEndAltShiftPageUpAltShiftPageDownCtrlUpCtrlDownCtrlLeftCtrlRightCtrlHomeCtrlEndCtrlBackspaceCtrlDeleteCtrlPageUpCtrlPageDownAltCtrlAltCtrlAltUpCtrlAltDownCtrlAltLeftCtrlAltRightCtrlAltHomeCtrlAltEndCtrlAltBackspaceCtrlAltDeleteCtrlAltPageUpCtrlAltPageDownCtrlShiftUpCtrlShiftDownCtrlShiftLeftCtrlShiftRightCtrlShiftHomeCtrlShiftEndCtrlShiftDeleteCtrlShiftPageUpCtrlShiftPageDownCtrlAltShiftUpCtrlAltShiftDownCtrlAltShiftLeftCtrlAltShiftRightCtrlAltShiftHomeCtrlAltShiftEndCtrlAltShiftDeleteCtrlAltShiftPageUpCtrlAltShiftPageDownInvalidFatalBracketedPasteBeginBracketedPasteEndMouseDoubleClickLeftClickRightClickSLeftClickSRightClickScrollUpScrollDownSScrollUpSScrollDownPreviewScrollUpPreviewScrollDownResizeChangeBackwardEOFStartLoadFocusOneZeroResultJumpJumpCancelClickHeaderClickFooterMulti"
|
||||
const _EventType_name = "RuneCtrlACtrlBCtrlCCtrlDCtrlECtrlFCtrlGCtrlHTabCtrlJCtrlKCtrlLEnterCtrlNCtrlOCtrlPCtrlQCtrlRCtrlSCtrlTCtrlUCtrlVCtrlWCtrlXCtrlYCtrlZEscCtrlSpaceCtrlBackSlashCtrlRightBracketCtrlCaretCtrlSlashShiftTabBackspaceDeletePageUpPageDownUpDownLeftRightHomeEndInsertShiftUpShiftDownShiftLeftShiftRightShiftDeleteShiftHomeShiftEndShiftPageUpShiftPageDownF1F2F3F4F5F6F7F8F9F10F11F12AltBackspaceAltUpAltDownAltLeftAltRightAltDeleteAltHomeAltEndAltPageUpAltPageDownAltShiftUpAltShiftDownAltShiftLeftAltShiftRightAltShiftDeleteAltShiftHomeAltShiftEndAltShiftPageUpAltShiftPageDownCtrlUpCtrlDownCtrlLeftCtrlRightCtrlHomeCtrlEndCtrlBackspaceCtrlDeleteCtrlPageUpCtrlPageDownAltCtrlAltCtrlAltUpCtrlAltDownCtrlAltLeftCtrlAltRightCtrlAltHomeCtrlAltEndCtrlAltBackspaceCtrlAltDeleteCtrlAltPageUpCtrlAltPageDownCtrlShiftUpCtrlShiftDownCtrlShiftLeftCtrlShiftRightCtrlShiftHomeCtrlShiftEndCtrlShiftDeleteCtrlShiftPageUpCtrlShiftPageDownCtrlAltShiftUpCtrlAltShiftDownCtrlAltShiftLeftCtrlAltShiftRightCtrlAltShiftHomeCtrlAltShiftEndCtrlAltShiftDeleteCtrlAltShiftPageUpCtrlAltShiftPageDownMouseDoubleClickLeftClickRightClickSLeftClickSRightClickScrollUpScrollDownSScrollUpSScrollDownPreviewScrollUpPreviewScrollDownInvalidFatalBracketedPasteBeginBracketedPasteEndResizeChangeBackwardEOFStartLoadFocusOneZeroResultJumpJumpCancelClickHeaderClickFooterMultiEvery"
|
||||
|
||||
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}
|
||||
|
||||
func (i EventType) String() string {
|
||||
if i < 0 || i >= EventType(len(_EventType_index)-1) {
|
||||
|
||||
+239
-182
@@ -13,7 +13,6 @@ import (
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/junegunn/fzf/src/util"
|
||||
"github.com/rivo/uniseg"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
@@ -26,6 +25,7 @@ const (
|
||||
escPollInterval = 5
|
||||
offsetPollTries = 10
|
||||
maxInputBuffer = 1024 * 1024
|
||||
maxSelectTries = 100
|
||||
)
|
||||
|
||||
const DefaultTtyDevice string = "/dev/tty"
|
||||
@@ -49,13 +49,26 @@ const DIM string = "\x1b[2m"
|
||||
const CR string = DIM + "␍"
|
||||
const LF string = DIM + "␊"
|
||||
|
||||
type getCharResult int
|
||||
|
||||
const (
|
||||
getCharSuccess getCharResult = iota
|
||||
getCharError
|
||||
getCharCancelled
|
||||
)
|
||||
|
||||
func (r getCharResult) ok() bool {
|
||||
return r == getCharSuccess
|
||||
}
|
||||
|
||||
func (r *LightRenderer) stderrInternal(str string, allowNLCR bool, resetCode string) {
|
||||
bytes := []byte(str)
|
||||
runes := []rune{}
|
||||
for len(bytes) > 0 {
|
||||
r, sz := utf8.DecodeRune(bytes)
|
||||
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 r == '\r' {
|
||||
runes = append(runes, []rune(CR+resetCode)...)
|
||||
@@ -104,6 +117,7 @@ type LightRenderer struct {
|
||||
clicks [][2]int
|
||||
ttyin *os.File
|
||||
ttyout *os.File
|
||||
cancel func()
|
||||
buffer []byte
|
||||
origState *term.State
|
||||
width int
|
||||
@@ -118,9 +132,9 @@ type LightRenderer struct {
|
||||
x int
|
||||
maxHeightFunc func(int) int
|
||||
showCursor bool
|
||||
mutex sync.Mutex
|
||||
|
||||
// Windows only
|
||||
mutex sync.Mutex
|
||||
ttyinChannel chan byte
|
||||
inHandle uintptr
|
||||
outHandle uintptr
|
||||
@@ -193,11 +207,6 @@ func (r *LightRenderer) Init() error {
|
||||
if r.fullscreen {
|
||||
r.smcup()
|
||||
} else {
|
||||
// We assume that --no-clear is used for repetitive relaunching of fzf.
|
||||
// So we do not clear the lower bottom of the screen.
|
||||
if r.clearOnExit {
|
||||
r.csi("J")
|
||||
}
|
||||
y, x := r.findOffset()
|
||||
r.mouse = r.mouse && y >= 0
|
||||
// When --no-clear is used for repetitive relaunching, there is a small
|
||||
@@ -208,6 +217,11 @@ func (r *LightRenderer) Init() error {
|
||||
r.upOneLine = true
|
||||
r.makeSpace()
|
||||
}
|
||||
// We assume that --no-clear is used for repetitive relaunching of fzf.
|
||||
// So we do not clear the lower bottom of the screen.
|
||||
if r.clearOnExit {
|
||||
r.csi("J")
|
||||
}
|
||||
for i := 1; i < r.MaxY(); i++ {
|
||||
r.makeSpace()
|
||||
}
|
||||
@@ -262,16 +276,18 @@ func getEnv(name string, defaultValue int) int {
|
||||
return atoi(env, defaultValue)
|
||||
}
|
||||
|
||||
func (r *LightRenderer) getBytes() ([]byte, error) {
|
||||
bytes, err := r.getBytesInternal(r.buffer, false)
|
||||
return bytes, err
|
||||
func (r *LightRenderer) getBytes(cancellable bool) ([]byte, getCharResult, error) {
|
||||
return r.getBytesInternal(cancellable, r.buffer, false)
|
||||
}
|
||||
|
||||
func (r *LightRenderer) getBytesInternal(buffer []byte, nonblock bool) ([]byte, error) {
|
||||
c, ok := r.getch(nonblock)
|
||||
if !nonblock && !ok {
|
||||
func (r *LightRenderer) getBytesInternal(cancellable bool, buffer []byte, nonblock bool) ([]byte, getCharResult, error) {
|
||||
c, result := r.getch(cancellable, nonblock)
|
||||
if result == getCharCancelled {
|
||||
return buffer, getCharCancelled, nil
|
||||
}
|
||||
if !nonblock && !result.ok() {
|
||||
r.Close()
|
||||
return nil, errors.New("failed to read " + DefaultTtyDevice)
|
||||
return nil, getCharError, errors.New("failed to read " + DefaultTtyDevice)
|
||||
}
|
||||
|
||||
retries := 0
|
||||
@@ -282,8 +298,8 @@ func (r *LightRenderer) getBytesInternal(buffer []byte, nonblock bool) ([]byte,
|
||||
|
||||
pc := c
|
||||
for {
|
||||
c, ok = r.getch(true)
|
||||
if !ok {
|
||||
c, result = r.getch(false, true)
|
||||
if !result.ok() {
|
||||
if retries > 0 {
|
||||
retries--
|
||||
time.Sleep(escPollInterval * time.Millisecond)
|
||||
@@ -302,20 +318,24 @@ func (r *LightRenderer) getBytesInternal(buffer []byte, nonblock bool) ([]byte,
|
||||
// so terminate fzf immediately.
|
||||
if len(buffer) > maxInputBuffer {
|
||||
r.Close()
|
||||
return nil, fmt.Errorf("input buffer overflow (%d): %v", len(buffer), buffer)
|
||||
return nil, getCharError, fmt.Errorf("input buffer overflow (%d): %v", len(buffer), buffer)
|
||||
}
|
||||
}
|
||||
|
||||
return buffer, nil
|
||||
return buffer, getCharSuccess, nil
|
||||
}
|
||||
|
||||
func (r *LightRenderer) GetChar() Event {
|
||||
func (r *LightRenderer) GetChar(cancellable bool) Event {
|
||||
var err error
|
||||
var result getCharResult
|
||||
if len(r.buffer) == 0 {
|
||||
r.buffer, err = r.getBytes()
|
||||
r.buffer, result, err = r.getBytes(cancellable)
|
||||
if err != nil {
|
||||
return Event{Fatal, 0, nil}
|
||||
}
|
||||
if result == getCharCancelled {
|
||||
return Event{Invalid, 0, nil}
|
||||
}
|
||||
}
|
||||
if len(r.buffer) == 0 {
|
||||
return Event{Fatal, 0, nil}
|
||||
@@ -351,9 +371,14 @@ func (r *LightRenderer) GetChar() Event {
|
||||
ev := r.escSequence(&sz)
|
||||
// Second chance
|
||||
if ev.Type == Invalid {
|
||||
if r.buffer, err = r.getBytes(); err != nil {
|
||||
r.buffer, result, err = r.getBytes(true)
|
||||
if err != nil {
|
||||
return Event{Fatal, 0, nil}
|
||||
}
|
||||
if result == getCharCancelled {
|
||||
return Event{Invalid, 0, nil}
|
||||
}
|
||||
|
||||
ev = r.escSequence(&sz)
|
||||
}
|
||||
return ev
|
||||
@@ -371,6 +396,21 @@ func (r *LightRenderer) GetChar() Event {
|
||||
return Event{Rune, char, nil}
|
||||
}
|
||||
|
||||
func (r *LightRenderer) CancelGetChar() {
|
||||
r.mutex.Lock()
|
||||
if r.cancel != nil {
|
||||
r.cancel()
|
||||
r.cancel = nil
|
||||
}
|
||||
r.mutex.Unlock()
|
||||
}
|
||||
|
||||
func (r *LightRenderer) setCancel(f func()) {
|
||||
r.mutex.Lock()
|
||||
r.cancel = f
|
||||
r.mutex.Unlock()
|
||||
}
|
||||
|
||||
func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
if len(r.buffer) < 2 {
|
||||
return Event{Esc, 0, nil}
|
||||
@@ -650,6 +690,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
switch r.buffer[4] {
|
||||
case '1', '2', '3', '4', '5', '6', '7', '8', '9':
|
||||
// Kitty iTerm2 WezTerm
|
||||
// ARROW "\e[1;1D"
|
||||
// SHIFT-ARROW "\e[1;2D"
|
||||
// ALT-SHIFT-ARROW "\e[1;4D" "\e[1;10D" "\e[1;4D"
|
||||
// CTRL-SHIFT-ARROW "\e[1;6D" N/A
|
||||
@@ -704,6 +745,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
if shift {
|
||||
return Event{ShiftUp, 0, nil}
|
||||
}
|
||||
return Event{Up, 0, nil}
|
||||
case 'B':
|
||||
if ctrlAltShift {
|
||||
return Event{CtrlAltShiftDown, 0, nil}
|
||||
@@ -726,6 +768,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
if shift {
|
||||
return Event{ShiftDown, 0, nil}
|
||||
}
|
||||
return Event{Down, 0, nil}
|
||||
case 'C':
|
||||
if ctrlAltShift {
|
||||
return Event{CtrlAltShiftRight, 0, nil}
|
||||
@@ -748,6 +791,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
if alt {
|
||||
return Event{AltRight, 0, nil}
|
||||
}
|
||||
return Event{Right, 0, nil}
|
||||
case 'D':
|
||||
if ctrlAltShift {
|
||||
return Event{CtrlAltShiftLeft, 0, nil}
|
||||
@@ -770,6 +814,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
if shift {
|
||||
return Event{ShiftLeft, 0, nil}
|
||||
}
|
||||
return Event{Left, 0, nil}
|
||||
case 'H':
|
||||
if ctrlAltShift {
|
||||
return Event{CtrlAltShiftHome, 0, nil}
|
||||
@@ -792,6 +837,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
if shift {
|
||||
return Event{ShiftHome, 0, nil}
|
||||
}
|
||||
return Event{Home, 0, nil}
|
||||
case 'F':
|
||||
if ctrlAltShift {
|
||||
return Event{CtrlAltShiftEnd, 0, nil}
|
||||
@@ -814,6 +860,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
if shift {
|
||||
return Event{ShiftEnd, 0, nil}
|
||||
}
|
||||
return Event{End, 0, nil}
|
||||
}
|
||||
} // r.buffer[4]
|
||||
} // r.buffer[3]
|
||||
@@ -1033,8 +1080,8 @@ func (r *LightRenderer) MaxY() int {
|
||||
}
|
||||
|
||||
func (r *LightRenderer) NewWindow(top int, left int, width int, height int, windowType WindowType, borderStyle BorderStyle, erase bool) Window {
|
||||
width = util.Max(0, width)
|
||||
height = util.Max(0, height)
|
||||
width = max(0, width)
|
||||
height = max(0, height)
|
||||
w := &LightWindow{
|
||||
renderer: r,
|
||||
colored: r.theme.Colored,
|
||||
@@ -1083,127 +1130,144 @@ func (w *LightWindow) DrawHBorder() {
|
||||
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) {
|
||||
if w.height == 0 {
|
||||
return
|
||||
}
|
||||
switch w.border.shape {
|
||||
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble:
|
||||
w.drawBorderAround(onlyHorizontal)
|
||||
case BorderHorizontal:
|
||||
w.drawBorderHorizontal(true, true)
|
||||
case BorderVertical:
|
||||
if onlyHorizontal {
|
||||
return
|
||||
}
|
||||
w.drawBorderVertical(true, true)
|
||||
case BorderTop:
|
||||
w.drawBorderHorizontal(true, false)
|
||||
case BorderBottom:
|
||||
w.drawBorderHorizontal(false, true)
|
||||
case BorderLeft:
|
||||
if onlyHorizontal {
|
||||
return
|
||||
}
|
||||
w.drawBorderVertical(true, false)
|
||||
case BorderRight:
|
||||
if onlyHorizontal {
|
||||
return
|
||||
}
|
||||
w.drawBorderVertical(false, true)
|
||||
shape := w.border.shape
|
||||
if shape == BorderNone {
|
||||
return
|
||||
}
|
||||
}
|
||||
color := BorderColor(w.windowType)
|
||||
hasLeft := shape.HasLeft()
|
||||
hasRight := shape.HasRight()
|
||||
|
||||
func (w *LightWindow) drawBorderHorizontal(top, bottom bool) {
|
||||
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 top {
|
||||
w.Move(0, 0)
|
||||
w.CPrint(color, repeat(w.border.top, w.width/hw))
|
||||
}
|
||||
|
||||
if bottom {
|
||||
w.Move(w.height-1, 0)
|
||||
w.CPrint(color, repeat(w.border.bottom, w.width/hw))
|
||||
}
|
||||
}
|
||||
|
||||
func (w *LightWindow) drawBorderVertical(left, right bool) {
|
||||
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++ {
|
||||
if left {
|
||||
w.Move(y, 0)
|
||||
w.CPrint(color, string(w.border.left))
|
||||
w.CPrint(color, " ") // Margin
|
||||
if shape.HasTop() {
|
||||
var leftCap, rightCap rune
|
||||
if hasLeft {
|
||||
leftCap = w.border.topLeft
|
||||
}
|
||||
if right {
|
||||
w.Move(y, w.width-vw-1)
|
||||
w.CPrint(color, " ") // Margin
|
||||
w.CPrint(color, string(w.border.right))
|
||||
if hasRight {
|
||||
rightCap = w.border.topRight
|
||||
}
|
||||
w.drawHLine(0, w.border.top, leftCap, rightCap, color)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
tcw := runeWidth(w.border.topLeft) + runeWidth(w.border.topRight)
|
||||
bcw := runeWidth(w.border.bottomLeft) + runeWidth(w.border.bottomRight)
|
||||
rem := (w.width - tcw) % hw
|
||||
w.CPrint(color, string(w.border.topLeft)+repeat(w.border.top, (w.width-tcw)/hw)+repeat(' ', rem)+string(w.border.topRight))
|
||||
if !onlyHorizontal {
|
||||
if !onlyHorizontal && (hasLeft || hasRight) {
|
||||
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))
|
||||
for y := 0; y < w.height; y++ {
|
||||
// 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.CPrint(color, string(w.border.left)+" ")
|
||||
}
|
||||
if hasRight {
|
||||
w.Move(y, w.width-vw-1)
|
||||
w.CPrint(color, " "+string(w.border.right))
|
||||
}
|
||||
}
|
||||
}
|
||||
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))
|
||||
if 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) csi(code string) string {
|
||||
@@ -1283,7 +1347,18 @@ func attrCodes(attr Attr) []string {
|
||||
codes = append(codes, "3")
|
||||
}
|
||||
if (attr & Underline) > 0 {
|
||||
codes = append(codes, "4")
|
||||
switch attr.UnderlineStyle() {
|
||||
case UlStyleDouble:
|
||||
codes = append(codes, "4:2")
|
||||
case UlStyleCurly:
|
||||
codes = append(codes, "4:3")
|
||||
case UlStyleDotted:
|
||||
codes = append(codes, "4:4")
|
||||
case UlStyleDashed:
|
||||
codes = append(codes, "4:5")
|
||||
default:
|
||||
codes = append(codes, "4")
|
||||
}
|
||||
}
|
||||
if (attr & Blink) > 0 {
|
||||
codes = append(codes, "5")
|
||||
@@ -1321,8 +1396,27 @@ func colorCodes(fg Color, bg Color) []string {
|
||||
return codes
|
||||
}
|
||||
|
||||
func (w *LightWindow) csiColor(fg Color, bg Color, attr Attr) (bool, string) {
|
||||
func ulColorCode(c Color) string {
|
||||
if c == colDefault {
|
||||
return ""
|
||||
}
|
||||
if c.is24() {
|
||||
r := (c >> 16) & 0xff
|
||||
g := (c >> 8) & 0xff
|
||||
b := (c) & 0xff
|
||||
return fmt.Sprintf("58;2;%d;%d;%d", r, g, b)
|
||||
}
|
||||
if c >= 0 && c < 256 {
|
||||
return fmt.Sprintf("58;5;%d", c)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (w *LightWindow) csiColor(fg Color, bg Color, ul Color, attr Attr) (bool, string) {
|
||||
codes := append(attrCodes(attr), colorCodes(fg, bg)...)
|
||||
if ulCode := ulColorCode(ul); ulCode != "" {
|
||||
codes = append(codes, ulCode)
|
||||
}
|
||||
code := w.csi(";" + strings.Join(codes, ";") + "m")
|
||||
return len(codes) > 0, code
|
||||
}
|
||||
@@ -1336,65 +1430,28 @@ func cleanse(str string) string {
|
||||
}
|
||||
|
||||
func (w *LightWindow) CPrint(pair ColorPair, text string) {
|
||||
_, code := w.csiColor(pair.Fg(), pair.Bg(), pair.Attr())
|
||||
_, code := w.csiColor(pair.Fg(), pair.Bg(), pair.Ul(), pair.Attr())
|
||||
w.stderrInternal(cleanse(text), false, code)
|
||||
w.csi("0m")
|
||||
}
|
||||
|
||||
func (w *LightWindow) cprint2(fg Color, bg Color, attr Attr, text string) {
|
||||
hasColors, code := w.csiColor(fg, bg, attr)
|
||||
hasColors, code := w.csiColor(fg, bg, colDefault, attr)
|
||||
if hasColors {
|
||||
defer w.csi("0m")
|
||||
}
|
||||
w.stderrInternal(cleanse(text), false, code)
|
||||
}
|
||||
|
||||
type wrappedLine struct {
|
||||
text string
|
||||
displayWidth int
|
||||
}
|
||||
|
||||
func wrapLine(input string, prefixLength int, initialMax int, tabstop int, wrapSignWidth int) []wrappedLine {
|
||||
lines := []wrappedLine{}
|
||||
width := 0
|
||||
line := ""
|
||||
gr := uniseg.NewGraphemes(input)
|
||||
max := initialMax
|
||||
for gr.Next() {
|
||||
rs := gr.Runes()
|
||||
str := string(rs)
|
||||
var w int
|
||||
if len(rs) == 1 && rs[0] == '\t' {
|
||||
w = tabstop - (prefixLength+width)%tabstop
|
||||
str = repeat(' ', w)
|
||||
} else if rs[0] == '\r' {
|
||||
w++
|
||||
} else {
|
||||
w = uniseg.StringWidth(str)
|
||||
}
|
||||
width += w
|
||||
|
||||
if prefixLength+width <= max {
|
||||
line += str
|
||||
} else {
|
||||
lines = append(lines, wrappedLine{string(line), width - w})
|
||||
line = str
|
||||
prefixLength = 0
|
||||
width = w
|
||||
max = initialMax - wrapSignWidth
|
||||
}
|
||||
}
|
||||
lines = append(lines, wrappedLine{string(line), width})
|
||||
return lines
|
||||
}
|
||||
|
||||
func (w *LightWindow) fill(str string, resetCode string) FillReturn {
|
||||
allLines := strings.Split(str, "\n")
|
||||
for i, line := range allLines {
|
||||
lines := wrapLine(line, w.posx, w.width, w.tabstop, w.wrapSignWidth)
|
||||
lines := WrapLine(line, w.posx, w.width, w.tabstop, w.wrapSignWidth)
|
||||
for j, wl := range lines {
|
||||
w.stderrInternal(wl.text, false, resetCode)
|
||||
w.posx += wl.displayWidth
|
||||
if w.posx < w.width {
|
||||
w.stderrInternal(wl.Text, false, resetCode)
|
||||
w.posx += wl.DisplayWidth
|
||||
}
|
||||
|
||||
// Wrap line
|
||||
if j < len(lines)-1 || i < len(allLines)-1 {
|
||||
@@ -1432,7 +1489,7 @@ func (w *LightWindow) fill(str string, resetCode string) FillReturn {
|
||||
|
||||
func (w *LightWindow) setBg() string {
|
||||
if w.bg != colDefault {
|
||||
_, code := w.csiColor(colDefault, w.bg, AttrRegular)
|
||||
_, code := w.csiColor(colDefault, w.bg, colDefault, AttrRegular)
|
||||
return code
|
||||
}
|
||||
// Should clear dim attribute after ␍ in the preview window
|
||||
@@ -1454,7 +1511,7 @@ func (w *LightWindow) Fill(text string) FillReturn {
|
||||
return w.fill(text, code)
|
||||
}
|
||||
|
||||
func (w *LightWindow) CFill(fg Color, bg Color, attr Attr, text string) FillReturn {
|
||||
func (w *LightWindow) CFill(fg Color, bg Color, ul Color, attr Attr, text string) FillReturn {
|
||||
w.Move(w.posy, w.posx)
|
||||
if fg == colDefault {
|
||||
fg = w.fg
|
||||
@@ -1462,7 +1519,7 @@ func (w *LightWindow) CFill(fg Color, bg Color, attr Attr, text string) FillRetu
|
||||
if bg == colDefault {
|
||||
bg = w.bg
|
||||
}
|
||||
if hasColors, resetCode := w.csiColor(fg, bg, attr); hasColors {
|
||||
if hasColors, resetCode := w.csiColor(fg, bg, ul, attr); hasColors {
|
||||
defer w.csi("0m")
|
||||
return w.fill(text, resetCode)
|
||||
}
|
||||
|
||||
+18
-1
@@ -15,10 +15,27 @@ func TestLightRenderer(t *testing.T) {
|
||||
|
||||
light_renderer := renderer.(*LightRenderer)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
light_renderer.mutex.Lock()
|
||||
ready := light_renderer.cancel != nil
|
||||
light_renderer.mutex.Unlock()
|
||||
|
||||
if ready {
|
||||
light_renderer.CancelGetChar()
|
||||
break
|
||||
}
|
||||
}
|
||||
}()
|
||||
event := light_renderer.GetChar(true)
|
||||
if event.Type != Invalid {
|
||||
t.Error("Not cancelled")
|
||||
}
|
||||
|
||||
assertCharSequence := func(sequence string, name string) {
|
||||
bytes := []byte(sequence)
|
||||
light_renderer.buffer = bytes
|
||||
event := light_renderer.GetChar()
|
||||
event := light_renderer.GetChar(true)
|
||||
if event.KeyName() != name {
|
||||
t.Errorf(
|
||||
"sequence: %q | %v | '%s' (%s) != %s",
|
||||
|
||||
+55
-8
@@ -99,7 +99,7 @@ func (r *LightRenderer) findOffset() (row int, col int) {
|
||||
var err error
|
||||
bytes := []byte{}
|
||||
for tries := range offsetPollTries {
|
||||
bytes, err = r.getBytesInternal(bytes, tries > 0)
|
||||
bytes, _, err = r.getBytesInternal(false, bytes, tries > 0)
|
||||
if err != nil {
|
||||
return -1, -1
|
||||
}
|
||||
@@ -114,15 +114,62 @@ func (r *LightRenderer) findOffset() (row int, col int) {
|
||||
return -1, -1
|
||||
}
|
||||
|
||||
func (r *LightRenderer) getch(nonblock bool) (int, bool) {
|
||||
b := make([]byte, 1)
|
||||
func (r *LightRenderer) getch(cancellable bool, nonblock bool) (int, getCharResult) {
|
||||
fd := r.fd()
|
||||
util.SetNonblock(r.ttyin, nonblock)
|
||||
_, err := util.Read(fd, b)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
getter := func() (int, getCharResult) {
|
||||
b := make([]byte, 1)
|
||||
util.SetNonblock(r.ttyin, nonblock)
|
||||
_, err := util.Read(fd, b)
|
||||
if err != nil {
|
||||
return 0, getCharError
|
||||
}
|
||||
return int(b[0]), getCharSuccess
|
||||
}
|
||||
return int(b[0]), true
|
||||
if nonblock || !cancellable {
|
||||
return getter()
|
||||
}
|
||||
|
||||
rpipe, wpipe, err := os.Pipe()
|
||||
if err != nil {
|
||||
// Fallback to blocking read without cancellation
|
||||
return getter()
|
||||
}
|
||||
r.setCancel(func() {
|
||||
wpipe.Write([]byte{0})
|
||||
})
|
||||
defer func() {
|
||||
r.setCancel(nil)
|
||||
rpipe.Close()
|
||||
wpipe.Close()
|
||||
}()
|
||||
|
||||
cancelFd := int(rpipe.Fd())
|
||||
for range maxSelectTries {
|
||||
var rfds unix.FdSet
|
||||
limit := len(rfds.Bits) * unix.NFDBITS
|
||||
if fd >= limit || cancelFd >= limit {
|
||||
return getter()
|
||||
}
|
||||
|
||||
rfds.Set(fd)
|
||||
rfds.Set(cancelFd)
|
||||
_, err := unix.Select(max(fd, cancelFd)+1, &rfds, nil, nil, nil)
|
||||
if err != nil {
|
||||
if err == syscall.EINTR {
|
||||
continue
|
||||
}
|
||||
return 0, getCharError
|
||||
}
|
||||
|
||||
if rfds.IsSet(cancelFd) {
|
||||
return 0, getCharCancelled
|
||||
}
|
||||
|
||||
if rfds.IsSet(fd) {
|
||||
return getter()
|
||||
}
|
||||
}
|
||||
return 0, getCharError
|
||||
}
|
||||
|
||||
func (r *LightRenderer) Size() TermSize {
|
||||
|
||||
+27
-10
@@ -151,16 +151,33 @@ func (r *LightRenderer) findOffset() (row int, col int) {
|
||||
return int(bufferInfo.CursorPosition.Y), int(bufferInfo.CursorPosition.X)
|
||||
}
|
||||
|
||||
func (r *LightRenderer) getch(nonblock bool) (int, bool) {
|
||||
if nonblock {
|
||||
select {
|
||||
case bc := <-r.ttyinChannel:
|
||||
return int(bc), true
|
||||
case <-time.After(timeoutInterval * time.Millisecond):
|
||||
return 0, false
|
||||
}
|
||||
} else {
|
||||
func (r *LightRenderer) getch(cancellable bool, nonblock bool) (int, getCharResult) {
|
||||
if !nonblock && !cancellable {
|
||||
bc := <-r.ttyinChannel
|
||||
return int(bc), true
|
||||
return int(bc), getCharSuccess
|
||||
}
|
||||
|
||||
var timeout <-chan time.Time
|
||||
if nonblock {
|
||||
timeout = time.After(timeoutInterval * time.Millisecond)
|
||||
}
|
||||
|
||||
var cancel chan struct{}
|
||||
if cancellable {
|
||||
cancel = make(chan struct{})
|
||||
r.setCancel(func() {
|
||||
close(cancel)
|
||||
})
|
||||
defer r.setCancel(nil)
|
||||
}
|
||||
|
||||
select {
|
||||
case bc := <-r.ttyinChannel:
|
||||
return int(bc), getCharSuccess
|
||||
case <-cancel:
|
||||
return 0, getCharCancelled
|
||||
case <-timeout:
|
||||
// NOTE: not really an error
|
||||
return 0, getCharError
|
||||
}
|
||||
}
|
||||
|
||||
+227
-110
@@ -5,6 +5,7 @@ package tui
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
@@ -53,6 +54,7 @@ type TcellWindow struct {
|
||||
showCursor bool
|
||||
wrapSign string
|
||||
wrapSignWidth int
|
||||
tabstop int
|
||||
}
|
||||
|
||||
func (w *TcellWindow) Top() int {
|
||||
@@ -246,7 +248,7 @@ func (r *FullscreenRenderer) Size() TermSize {
|
||||
return TermSize{lines, cols, 0, 0}
|
||||
}
|
||||
|
||||
func (r *FullscreenRenderer) GetChar() Event {
|
||||
func (r *FullscreenRenderer) GetChar(cancellable bool) Event {
|
||||
ev := _screen.PollEvent()
|
||||
switch ev := ev.(type) {
|
||||
case *tcell.EventPaste:
|
||||
@@ -703,6 +705,10 @@ func (r *FullscreenRenderer) GetChar() Event {
|
||||
return Event{Invalid, 0, nil}
|
||||
}
|
||||
|
||||
func (r *FullscreenRenderer) CancelGetChar() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
func (r *FullscreenRenderer) Pause(clear bool) {
|
||||
if clear {
|
||||
_screen.Suspend()
|
||||
@@ -729,8 +735,8 @@ func (r *FullscreenRenderer) RefreshWindows(windows []Window) {
|
||||
}
|
||||
|
||||
func (r *FullscreenRenderer) NewWindow(top int, left int, width int, height int, windowType WindowType, borderStyle BorderStyle, erase bool) Window {
|
||||
width = util.Max(0, width)
|
||||
height = util.Max(0, height)
|
||||
width = max(0, width)
|
||||
height = max(0, height)
|
||||
normal := ColBorder
|
||||
switch windowType {
|
||||
case WindowList:
|
||||
@@ -753,7 +759,8 @@ func (r *FullscreenRenderer) NewWindow(top int, left int, width int, height int,
|
||||
height: height,
|
||||
normal: normal,
|
||||
borderStyle: borderStyle,
|
||||
showCursor: r.showCursor}
|
||||
showCursor: r.showCursor,
|
||||
tabstop: r.tabstop}
|
||||
w.Erase()
|
||||
return w
|
||||
}
|
||||
@@ -821,6 +828,21 @@ func (w *TcellWindow) withUrl(style tcell.Style) tcell.Style {
|
||||
return style
|
||||
}
|
||||
|
||||
func underlineStyleFromAttr(a Attr) tcell.UnderlineStyle {
|
||||
switch a.UnderlineStyle() {
|
||||
case UlStyleDouble:
|
||||
return tcell.UnderlineStyleDouble
|
||||
case UlStyleCurly:
|
||||
return tcell.UnderlineStyleCurly
|
||||
case UlStyleDotted:
|
||||
return tcell.UnderlineStyleDotted
|
||||
case UlStyleDashed:
|
||||
return tcell.UnderlineStyleDashed
|
||||
default:
|
||||
return tcell.UnderlineStyleSolid
|
||||
}
|
||||
}
|
||||
|
||||
func (w *TcellWindow) printString(text string, pair ColorPair) {
|
||||
lx := 0
|
||||
a := pair.Attr()
|
||||
@@ -829,11 +851,18 @@ func (w *TcellWindow) printString(text string, pair ColorPair) {
|
||||
if a&AttrClear == 0 {
|
||||
style = style.
|
||||
Reverse(a&Attr(tcell.AttrReverse) != 0).
|
||||
Underline(a&Attr(tcell.AttrUnderline) != 0).
|
||||
StrikeThrough(a&Attr(tcell.AttrStrikeThrough) != 0).
|
||||
Italic(a&Attr(tcell.AttrItalic) != 0).
|
||||
Blink(a&Attr(tcell.AttrBlink) != 0).
|
||||
Dim(a&Attr(tcell.AttrDim) != 0)
|
||||
if a&Attr(tcell.AttrUnderline) != 0 {
|
||||
style = style.Underline(underlineStyleFromAttr(a))
|
||||
if pair.Ul() != colDefault {
|
||||
style = style.Underline(asTcellColor(pair.Ul()))
|
||||
}
|
||||
} else {
|
||||
style = style.Underline(false)
|
||||
}
|
||||
}
|
||||
style = w.withUrl(style)
|
||||
|
||||
@@ -868,10 +897,8 @@ func (w *TcellWindow) CPrint(pair ColorPair, text string) {
|
||||
w.printString(text, pair)
|
||||
}
|
||||
|
||||
func (w *TcellWindow) fillString(text string, pair ColorPair) FillReturn {
|
||||
lx := 0
|
||||
func (w *TcellWindow) pairStyle(pair ColorPair) tcell.Style {
|
||||
a := pair.Attr()
|
||||
|
||||
var style tcell.Style
|
||||
if w.color {
|
||||
style = pair.style()
|
||||
@@ -883,64 +910,73 @@ func (w *TcellWindow) fillString(text string, pair ColorPair) FillReturn {
|
||||
Bold(a&Attr(tcell.AttrBold) != 0 || a&BoldForce != 0).
|
||||
Dim(a&Attr(tcell.AttrDim) != 0).
|
||||
Reverse(a&Attr(tcell.AttrReverse) != 0).
|
||||
Underline(a&Attr(tcell.AttrUnderline) != 0).
|
||||
StrikeThrough(a&Attr(tcell.AttrStrikeThrough) != 0).
|
||||
Italic(a&Attr(tcell.AttrItalic) != 0)
|
||||
style = w.withUrl(style)
|
||||
if a&Attr(tcell.AttrUnderline) != 0 {
|
||||
style = style.Underline(underlineStyleFromAttr(a))
|
||||
if pair.Ul() != colDefault {
|
||||
style = style.Underline(asTcellColor(pair.Ul()))
|
||||
}
|
||||
} else {
|
||||
style = style.Underline(false)
|
||||
}
|
||||
return w.withUrl(style)
|
||||
}
|
||||
|
||||
func (w *TcellWindow) renderGraphemes(text string, style tcell.Style) {
|
||||
gr := uniseg.NewGraphemes(text)
|
||||
Loop:
|
||||
for gr.Next() {
|
||||
st := style
|
||||
rs := gr.Runes()
|
||||
if len(rs) == 1 {
|
||||
r := rs[0]
|
||||
switch r {
|
||||
case '\r':
|
||||
st = style.Dim(true)
|
||||
rs[0] = '␍'
|
||||
case '\n':
|
||||
w.lastY++
|
||||
w.lastX = 0
|
||||
lx = 0
|
||||
continue Loop
|
||||
}
|
||||
}
|
||||
|
||||
// word wrap:
|
||||
xPos := w.left + w.lastX + lx
|
||||
if xPos >= w.left+w.width {
|
||||
w.lastY++
|
||||
if w.lastY >= w.height {
|
||||
return FillSuspend
|
||||
}
|
||||
w.lastX = 0
|
||||
lx = 0
|
||||
xPos = w.left
|
||||
sign := w.wrapSign
|
||||
if w.wrapSignWidth > w.width {
|
||||
runes, _ := util.Truncate(sign, w.width)
|
||||
sign = string(runes)
|
||||
}
|
||||
wgr := uniseg.NewGraphemes(sign)
|
||||
for wgr.Next() {
|
||||
rs := wgr.Runes()
|
||||
_screen.SetContent(w.left+lx, w.top+w.lastY, rs[0], rs[1:], style.Dim(true))
|
||||
lx += uniseg.StringWidth(string(rs))
|
||||
}
|
||||
xPos = w.left + lx
|
||||
if len(rs) == 1 && rs[0] == '\r' {
|
||||
st = style.Dim(true)
|
||||
rs[0] = '␍'
|
||||
}
|
||||
|
||||
xPos := w.left + w.lastX
|
||||
yPos := w.top + w.lastY
|
||||
if yPos >= (w.top + w.height) {
|
||||
return FillSuspend
|
||||
if xPos < (w.left+w.width) && yPos < (w.top+w.height) {
|
||||
_screen.SetContent(xPos, yPos, rs[0], rs[1:], st)
|
||||
}
|
||||
|
||||
_screen.SetContent(xPos, yPos, rs[0], rs[1:], st)
|
||||
lx += util.StringWidth(string(rs))
|
||||
w.lastX += util.StringWidth(string(rs))
|
||||
}
|
||||
w.lastX += lx
|
||||
if w.lastX == w.width {
|
||||
}
|
||||
|
||||
func (w *TcellWindow) renderWrapSign(style tcell.Style) {
|
||||
sign := w.wrapSign
|
||||
if w.wrapSignWidth > w.width {
|
||||
runes, _ := util.Truncate(sign, w.width)
|
||||
sign = string(runes)
|
||||
}
|
||||
gr := uniseg.NewGraphemes(sign)
|
||||
for gr.Next() {
|
||||
rs := gr.Runes()
|
||||
_screen.SetContent(w.left+w.lastX, w.top+w.lastY, rs[0], rs[1:], style.Dim(true))
|
||||
w.lastX += uniseg.StringWidth(string(rs))
|
||||
}
|
||||
}
|
||||
|
||||
func (w *TcellWindow) fillString(text string, pair ColorPair) FillReturn {
|
||||
style := w.pairStyle(pair)
|
||||
|
||||
for i, segment := range strings.Split(text, "\n") {
|
||||
for j, wl := range WrapLine(segment, w.lastX, w.width, w.tabstop, w.wrapSignWidth) {
|
||||
if i > 0 || j > 0 {
|
||||
w.lastY++
|
||||
if w.lastY >= w.height {
|
||||
return FillSuspend
|
||||
}
|
||||
w.lastX = 0
|
||||
if j > 0 {
|
||||
w.renderWrapSign(style)
|
||||
}
|
||||
}
|
||||
if w.lastX < w.width {
|
||||
w.renderGraphemes(wl.Text, style)
|
||||
}
|
||||
}
|
||||
}
|
||||
if w.lastX >= w.width {
|
||||
w.lastY++
|
||||
w.lastX = 0
|
||||
return FillNextLine
|
||||
@@ -963,14 +999,14 @@ func (w *TcellWindow) LinkEnd() {
|
||||
w.params = nil
|
||||
}
|
||||
|
||||
func (w *TcellWindow) CFill(fg Color, bg Color, a Attr, str string) FillReturn {
|
||||
func (w *TcellWindow) CFill(fg Color, bg Color, ul Color, a Attr, str string) FillReturn {
|
||||
if fg == colDefault {
|
||||
fg = w.normal.Fg()
|
||||
}
|
||||
if bg == colDefault {
|
||||
bg = w.normal.Bg()
|
||||
}
|
||||
return w.fillString(str, NewColorPair(fg, bg, a))
|
||||
return w.fillString(str, NewColorPair(fg, bg, a).WithUl(ul))
|
||||
}
|
||||
|
||||
func (w *TcellWindow) DrawBorder() {
|
||||
@@ -981,6 +1017,115 @@ func (w *TcellWindow) DrawHBorder() {
|
||||
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) {
|
||||
if w.height == 0 {
|
||||
return
|
||||
@@ -995,72 +1140,44 @@ func (w *TcellWindow) drawBorder(onlyHorizontal bool) {
|
||||
top := w.top
|
||||
bot := top + w.height
|
||||
|
||||
var style tcell.Style
|
||||
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()
|
||||
}
|
||||
style := w.borderStyleFor(w.windowType)
|
||||
|
||||
hw := runeWidth(w.borderStyle.top)
|
||||
switch shape {
|
||||
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble, BorderHorizontal, BorderTop:
|
||||
max := right - 2*hw
|
||||
if shape == BorderHorizontal || shape == BorderTop {
|
||||
max = right - hw
|
||||
hasLeft := shape.HasLeft()
|
||||
hasRight := shape.HasRight()
|
||||
|
||||
if shape.HasTop() {
|
||||
var leftCap, rightCap rune
|
||||
if hasLeft {
|
||||
leftCap = w.borderStyle.topLeft
|
||||
}
|
||||
// tcell has an issue displaying two overlapping wide runes
|
||||
// e.g. SetContent( HH )
|
||||
// SetContent( TR )
|
||||
// ==================
|
||||
// ( HH ) => TR is ignored
|
||||
for x := left; x <= max; x += hw {
|
||||
_screen.SetContent(x, top, w.borderStyle.top, nil, style)
|
||||
if hasRight {
|
||||
rightCap = w.borderStyle.topRight
|
||||
}
|
||||
w.drawHLine(top, w.borderStyle.top, leftCap, rightCap, style)
|
||||
}
|
||||
switch shape {
|
||||
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble, BorderHorizontal, BorderBottom:
|
||||
max := right - 2*hw
|
||||
if shape == BorderHorizontal || shape == BorderBottom {
|
||||
max = right - hw
|
||||
if shape.HasBottom() {
|
||||
var leftCap, rightCap rune
|
||||
if hasLeft {
|
||||
leftCap = w.borderStyle.bottomLeft
|
||||
}
|
||||
for x := left; x <= max; x += hw {
|
||||
_screen.SetContent(x, bot-1, w.borderStyle.bottom, nil, style)
|
||||
if hasRight {
|
||||
rightCap = w.borderStyle.bottomRight
|
||||
}
|
||||
w.drawHLine(bot-1, w.borderStyle.bottom, leftCap, rightCap, style)
|
||||
}
|
||||
if !onlyHorizontal {
|
||||
switch shape {
|
||||
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble, BorderVertical, BorderLeft:
|
||||
for y := top; y < bot; y++ {
|
||||
vw := runeWidth(w.borderStyle.right)
|
||||
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)
|
||||
}
|
||||
}
|
||||
switch shape {
|
||||
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble, BorderVertical, BorderRight:
|
||||
vw := runeWidth(w.borderStyle.right)
|
||||
for y := top; y < bot; y++ {
|
||||
if hasRight {
|
||||
_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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,7 +253,7 @@ func TestGetCharEventKey(t *testing.T) {
|
||||
{giveKey{tcell.KeyPause, 0, tcell.ModNone}, wantKey{Invalid, 0, nil}}, // unhandled
|
||||
|
||||
}
|
||||
r := NewFullscreenRenderer(&ColorTheme{}, false, false)
|
||||
r := NewFullscreenRenderer(&ColorTheme{}, false, false, 8)
|
||||
r.Init()
|
||||
|
||||
// run and evaluate the tests
|
||||
@@ -265,22 +265,22 @@ func TestGetCharEventKey(t *testing.T) {
|
||||
t.Logf("giveEvent = %T{key: %v, ch: %q (%[3]v), mod: %#04b}\n", giveEvent, giveEvent.Key(), giveEvent.Rune(), giveEvent.Modifiers())
|
||||
|
||||
// process the event in fzf and evaluate the test
|
||||
gotEvent := r.GetChar()
|
||||
gotEvent := r.GetChar(true)
|
||||
// skip Resize events, those are sometimes put in the buffer outside of this test
|
||||
if initialResizeAsInvalid && gotEvent.Type == Invalid {
|
||||
t.Logf("Resize as Invalid swallowed")
|
||||
initialResizeAsInvalid = false
|
||||
gotEvent = r.GetChar()
|
||||
gotEvent = r.GetChar(true)
|
||||
}
|
||||
if gotEvent.Type == Resize {
|
||||
t.Logf("Resize swallowed")
|
||||
gotEvent = r.GetChar()
|
||||
gotEvent = r.GetChar(true)
|
||||
}
|
||||
t.Logf("wantEvent = %T{Type: %v, Char: %q (%[3]v)}\n", test.wantKey, test.wantKey.Type, test.wantKey.Char)
|
||||
t.Logf("gotEvent = %T{Type: %v, Char: %q (%[3]v)}\n", gotEvent, gotEvent.Type, gotEvent.Char)
|
||||
|
||||
assert(t, "r.GetChar().Type", gotEvent.Type, test.wantKey.Type)
|
||||
assert(t, "r.GetChar().Char", gotEvent.Char, test.wantKey.Char)
|
||||
assert(t, "r.GetChar(true).Type", gotEvent.Type, test.wantKey.Type)
|
||||
assert(t, "r.GetChar(true).Char", gotEvent.Char, test.wantKey.Char)
|
||||
}
|
||||
|
||||
r.Close()
|
||||
|
||||
+293
-84
@@ -2,7 +2,9 @@ package tui
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/junegunn/fzf/src/util"
|
||||
"github.com/rivo/uniseg"
|
||||
@@ -17,15 +19,34 @@ const (
|
||||
BoldForce = Attr(1 << 10)
|
||||
FullBg = Attr(1 << 11)
|
||||
Strip = Attr(1 << 12)
|
||||
|
||||
// Underline style stored in bits 13-15 (3 bits, values 0-4)
|
||||
// Only meaningful when the Underline attribute bit is also set.
|
||||
// 0 = solid (default)
|
||||
UnderlineStyleShift = 13
|
||||
UnderlineStyleMask = Attr(0b111 << UnderlineStyleShift)
|
||||
UlStyleDouble = Attr(0b001 << UnderlineStyleShift)
|
||||
UlStyleCurly = Attr(0b010 << UnderlineStyleShift)
|
||||
UlStyleDotted = Attr(0b011 << UnderlineStyleShift)
|
||||
UlStyleDashed = Attr(0b100 << UnderlineStyleShift)
|
||||
)
|
||||
|
||||
func (a Attr) UnderlineStyle() Attr {
|
||||
return a & UnderlineStyleMask
|
||||
}
|
||||
|
||||
func (a Attr) Merge(b Attr) Attr {
|
||||
if b&AttrRegular > 0 {
|
||||
// Only keep bold attribute set by the system
|
||||
return (b &^ AttrRegular) | (a & BoldForce)
|
||||
}
|
||||
|
||||
return (a &^ AttrRegular) | b
|
||||
merged := (a &^ AttrRegular) | b
|
||||
// When b sets Underline, use b's underline style instead of OR'ing
|
||||
if b&Underline > 0 {
|
||||
merged = (merged &^ UnderlineStyleMask) | (b & UnderlineStyleMask)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// Types of user action
|
||||
@@ -176,11 +197,6 @@ const (
|
||||
CtrlAltShiftPageUp
|
||||
CtrlAltShiftPageDown
|
||||
|
||||
Invalid
|
||||
Fatal
|
||||
BracketedPasteBegin
|
||||
BracketedPasteEnd
|
||||
|
||||
Mouse
|
||||
DoubleClick
|
||||
LeftClick
|
||||
@@ -194,7 +210,15 @@ const (
|
||||
PreviewScrollUp
|
||||
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
|
||||
Change
|
||||
BackwardEOF
|
||||
@@ -209,6 +233,7 @@ const (
|
||||
ClickHeader
|
||||
ClickFooter
|
||||
Multi
|
||||
Every
|
||||
)
|
||||
|
||||
func (t EventType) AsEvent() Event {
|
||||
@@ -228,6 +253,12 @@ func (e Event) Comparable() Event {
|
||||
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 {
|
||||
if me := e.MouseEvent; me != nil {
|
||||
return me.Name()
|
||||
@@ -352,6 +383,7 @@ const (
|
||||
type ColorPair struct {
|
||||
fg Color
|
||||
bg Color
|
||||
ul Color
|
||||
attr Attr
|
||||
}
|
||||
|
||||
@@ -363,11 +395,11 @@ func HexToColor(rrggbb string) Color {
|
||||
}
|
||||
|
||||
func NewColorPair(fg Color, bg Color, attr Attr) ColorPair {
|
||||
return ColorPair{fg, bg, attr}
|
||||
return ColorPair{fg, bg, colDefault, attr}
|
||||
}
|
||||
|
||||
func NoColorPair() ColorPair {
|
||||
return ColorPair{-1, -1, 0}
|
||||
return ColorPair{-1, -1, -1, 0}
|
||||
}
|
||||
|
||||
func (p ColorPair) Fg() Color {
|
||||
@@ -378,6 +410,16 @@ func (p ColorPair) Bg() Color {
|
||||
return p.bg
|
||||
}
|
||||
|
||||
func (p ColorPair) Ul() Color {
|
||||
return p.ul
|
||||
}
|
||||
|
||||
func (p ColorPair) WithUl(ul Color) ColorPair {
|
||||
dup := p
|
||||
dup.ul = ul
|
||||
return dup
|
||||
}
|
||||
|
||||
func (p ColorPair) Attr() Attr {
|
||||
return p.attr
|
||||
}
|
||||
@@ -404,6 +446,9 @@ func (p ColorPair) merge(other ColorPair, except Color) ColorPair {
|
||||
if other.bg != except {
|
||||
dup.bg = other.bg
|
||||
}
|
||||
if other.ul != except {
|
||||
dup.ul = other.ul
|
||||
}
|
||||
return dup
|
||||
}
|
||||
|
||||
@@ -413,15 +458,21 @@ func (p ColorPair) WithAttr(attr Attr) ColorPair {
|
||||
return dup
|
||||
}
|
||||
|
||||
func (p ColorPair) WithNewAttr(attr Attr) ColorPair {
|
||||
dup := p
|
||||
dup.attr = attr
|
||||
return dup
|
||||
}
|
||||
|
||||
func (p ColorPair) WithFg(fg ColorAttr) ColorPair {
|
||||
dup := p
|
||||
fgPair := ColorPair{fg.Color, colUndefined, fg.Attr}
|
||||
fgPair := ColorPair{fg.Color, colUndefined, colUndefined, fg.Attr}
|
||||
return dup.Merge(fgPair)
|
||||
}
|
||||
|
||||
func (p ColorPair) WithBg(bg ColorAttr) ColorPair {
|
||||
dup := p
|
||||
bgPair := ColorPair{colUndefined, bg.Color, bg.Attr}
|
||||
bgPair := ColorPair{colUndefined, bg.Color, colUndefined, bg.Attr}
|
||||
return dup.Merge(bgPair)
|
||||
}
|
||||
|
||||
@@ -456,6 +507,7 @@ type ColorTheme struct {
|
||||
PreviewBg ColorAttr
|
||||
DarkBg ColorAttr
|
||||
Gutter ColorAttr
|
||||
AltGutter ColorAttr
|
||||
Prompt ColorAttr
|
||||
InputBg ColorAttr
|
||||
InputBorder ColorAttr
|
||||
@@ -465,7 +517,7 @@ type ColorTheme struct {
|
||||
CurrentMatch ColorAttr
|
||||
Spinner ColorAttr
|
||||
Info ColorAttr
|
||||
Cursor ColorAttr
|
||||
Pointer ColorAttr
|
||||
Marker ColorAttr
|
||||
Header ColorAttr
|
||||
HeaderBg ColorAttr
|
||||
@@ -485,6 +537,8 @@ type ColorTheme struct {
|
||||
ListLabel ColorAttr
|
||||
ListBorder 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 {
|
||||
@@ -552,11 +606,13 @@ const (
|
||||
BorderBottom
|
||||
BorderLeft
|
||||
BorderRight
|
||||
BorderInline
|
||||
BorderDashed
|
||||
)
|
||||
|
||||
func (s BorderShape) HasLeft() bool {
|
||||
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 true
|
||||
@@ -564,7 +620,7 @@ func (s BorderShape) HasLeft() bool {
|
||||
|
||||
func (s BorderShape) HasRight() bool {
|
||||
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 true
|
||||
@@ -572,7 +628,7 @@ func (s BorderShape) HasRight() bool {
|
||||
|
||||
func (s BorderShape) HasTop() bool {
|
||||
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 true
|
||||
@@ -580,7 +636,7 @@ func (s BorderShape) HasTop() bool {
|
||||
|
||||
func (s BorderShape) HasBottom() bool {
|
||||
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 true
|
||||
@@ -600,6 +656,8 @@ type BorderStyle struct {
|
||||
topRight rune
|
||||
bottomLeft rune
|
||||
bottomRight rune
|
||||
leftMid rune
|
||||
rightMid rune
|
||||
}
|
||||
|
||||
type BorderCharacter int
|
||||
@@ -615,7 +673,9 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
|
||||
topLeft: ' ',
|
||||
topRight: ' ',
|
||||
bottomLeft: ' ',
|
||||
bottomRight: ' '}
|
||||
bottomRight: ' ',
|
||||
leftMid: ' ',
|
||||
rightMid: ' '}
|
||||
}
|
||||
if !unicode {
|
||||
return BorderStyle{
|
||||
@@ -628,6 +688,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
|
||||
topRight: '+',
|
||||
bottomLeft: '+',
|
||||
bottomRight: '+',
|
||||
leftMid: '+',
|
||||
rightMid: '+',
|
||||
}
|
||||
}
|
||||
switch shape {
|
||||
@@ -642,6 +704,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
|
||||
topRight: '┐',
|
||||
bottomLeft: '└',
|
||||
bottomRight: '┘',
|
||||
leftMid: '├',
|
||||
rightMid: '┤',
|
||||
}
|
||||
case BorderBold:
|
||||
return BorderStyle{
|
||||
@@ -654,6 +718,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
|
||||
topRight: '┓',
|
||||
bottomLeft: '┗',
|
||||
bottomRight: '┛',
|
||||
leftMid: '┣',
|
||||
rightMid: '┫',
|
||||
}
|
||||
case BorderBlock:
|
||||
// ▛▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▜
|
||||
@@ -669,6 +735,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
|
||||
topRight: '▜',
|
||||
bottomLeft: '▙',
|
||||
bottomRight: '▟',
|
||||
leftMid: '▌',
|
||||
rightMid: '▐',
|
||||
}
|
||||
|
||||
case BorderThinBlock:
|
||||
@@ -685,6 +753,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
|
||||
topRight: '🭾',
|
||||
bottomLeft: '🭼',
|
||||
bottomRight: '🭿',
|
||||
leftMid: '▏',
|
||||
rightMid: '▕',
|
||||
}
|
||||
|
||||
case BorderDouble:
|
||||
@@ -698,6 +768,25 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
|
||||
topRight: '╗',
|
||||
bottomLeft: '╚',
|
||||
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{
|
||||
@@ -710,6 +799,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
|
||||
topRight: '╮',
|
||||
bottomLeft: '╰',
|
||||
bottomRight: '╯',
|
||||
leftMid: '├',
|
||||
rightMid: '┤',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -731,6 +822,35 @@ const (
|
||||
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 {
|
||||
DefaultTheme() *ColorTheme
|
||||
Init() error
|
||||
@@ -748,7 +868,8 @@ type Renderer interface {
|
||||
HideCursor()
|
||||
ShowCursor()
|
||||
|
||||
GetChar() Event
|
||||
GetChar(cancellable bool) Event
|
||||
CancelGetChar()
|
||||
|
||||
Top() int
|
||||
MaxX() int
|
||||
@@ -767,6 +888,19 @@ type Window interface {
|
||||
|
||||
DrawBorder()
|
||||
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()
|
||||
FinishFill()
|
||||
|
||||
@@ -781,7 +915,7 @@ type Window interface {
|
||||
Print(text string)
|
||||
CPrint(color ColorPair, text string)
|
||||
Fill(text string) FillReturn
|
||||
CFill(fg Color, bg Color, attr Attr, text string) FillReturn
|
||||
CFill(fg Color, bg Color, ul Color, attr Attr, text string) FillReturn
|
||||
LinkBegin(uri string, params string)
|
||||
LinkEnd()
|
||||
Erase()
|
||||
@@ -794,16 +928,18 @@ type FullscreenRenderer struct {
|
||||
theme *ColorTheme
|
||||
mouse bool
|
||||
forceBlack bool
|
||||
tabstop int
|
||||
prevDownTime time.Time
|
||||
clicks [][2]int
|
||||
showCursor bool
|
||||
}
|
||||
|
||||
func NewFullscreenRenderer(theme *ColorTheme, forceBlack bool, mouse bool) Renderer {
|
||||
func NewFullscreenRenderer(theme *ColorTheme, forceBlack bool, mouse bool, tabstop int) Renderer {
|
||||
r := &FullscreenRenderer{
|
||||
theme: theme,
|
||||
mouse: mouse,
|
||||
forceBlack: forceBlack,
|
||||
tabstop: tabstop,
|
||||
prevDownTime: time.Unix(0, 0),
|
||||
clicks: [][2]int{},
|
||||
showCursor: true}
|
||||
@@ -823,16 +959,18 @@ var (
|
||||
ColDisabled ColorPair
|
||||
ColGhost ColorPair
|
||||
ColMatch ColorPair
|
||||
ColCursor ColorPair
|
||||
ColCursorEmpty ColorPair
|
||||
ColCursorEmptyChar ColorPair
|
||||
ColPointer ColorPair
|
||||
ColPointerEmpty ColorPair
|
||||
ColPointerEmptyChar ColorPair
|
||||
ColAltPointerEmpty ColorPair
|
||||
ColAltPointerEmptyChar ColorPair
|
||||
ColMarker ColorPair
|
||||
ColSelected ColorPair
|
||||
ColSelectedMatch ColorPair
|
||||
ColCurrent ColorPair
|
||||
ColCurrentMatch ColorPair
|
||||
ColCurrentCursor ColorPair
|
||||
ColCurrentCursorEmpty ColorPair
|
||||
ColCurrentPointer ColorPair
|
||||
ColCurrentPointerEmpty ColorPair
|
||||
ColCurrentMarker ColorPair
|
||||
ColCurrentSelectedEmpty ColorPair
|
||||
ColSpinner ColorPair
|
||||
@@ -864,50 +1002,56 @@ func init() {
|
||||
undefined := ColorAttr{colUndefined, AttrUndefined}
|
||||
|
||||
NoColorTheme = &ColorTheme{
|
||||
Colored: false,
|
||||
Input: defaultColor,
|
||||
Fg: defaultColor,
|
||||
Bg: defaultColor,
|
||||
ListFg: defaultColor,
|
||||
ListBg: defaultColor,
|
||||
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,
|
||||
Fg: defaultColor,
|
||||
Bg: defaultColor,
|
||||
DarkBg: defaultColor,
|
||||
Prompt: defaultColor,
|
||||
Match: defaultColor,
|
||||
Spinner: defaultColor,
|
||||
Info: defaultColor,
|
||||
Pointer: defaultColor,
|
||||
Marker: defaultColor,
|
||||
Header: defaultColor,
|
||||
Footer: defaultColor,
|
||||
BorderLabel: defaultColor,
|
||||
// Derived colors. Left undefined so they inherit from a root.
|
||||
ListFg: undefined,
|
||||
ListBg: undefined,
|
||||
AltBg: undefined,
|
||||
SelectedFg: defaultColor,
|
||||
SelectedBg: defaultColor,
|
||||
SelectedMatch: defaultColor,
|
||||
DarkBg: defaultColor,
|
||||
Prompt: defaultColor,
|
||||
Match: defaultColor,
|
||||
SelectedFg: undefined,
|
||||
SelectedBg: undefined,
|
||||
SelectedMatch: undefined,
|
||||
Current: undefined,
|
||||
CurrentMatch: undefined,
|
||||
Spinner: defaultColor,
|
||||
Info: defaultColor,
|
||||
Cursor: defaultColor,
|
||||
Marker: defaultColor,
|
||||
Header: defaultColor,
|
||||
Border: undefined,
|
||||
BorderLabel: defaultColor,
|
||||
Ghost: undefined,
|
||||
Disabled: defaultColor,
|
||||
PreviewFg: defaultColor,
|
||||
PreviewBg: defaultColor,
|
||||
Disabled: undefined,
|
||||
PreviewFg: undefined,
|
||||
PreviewBg: undefined,
|
||||
Gutter: undefined,
|
||||
PreviewBorder: defaultColor,
|
||||
PreviewScrollbar: defaultColor,
|
||||
PreviewLabel: defaultColor,
|
||||
ListLabel: defaultColor,
|
||||
ListBorder: defaultColor,
|
||||
Separator: defaultColor,
|
||||
Scrollbar: defaultColor,
|
||||
InputBg: defaultColor,
|
||||
InputBorder: defaultColor,
|
||||
InputLabel: defaultColor,
|
||||
HeaderBg: defaultColor,
|
||||
HeaderBorder: defaultColor,
|
||||
HeaderLabel: defaultColor,
|
||||
FooterBg: defaultColor,
|
||||
FooterBorder: defaultColor,
|
||||
FooterLabel: defaultColor,
|
||||
GapLine: defaultColor,
|
||||
AltGutter: undefined,
|
||||
PreviewBorder: undefined,
|
||||
PreviewScrollbar: undefined,
|
||||
PreviewLabel: undefined,
|
||||
ListLabel: undefined,
|
||||
ListBorder: undefined,
|
||||
Separator: undefined,
|
||||
Scrollbar: undefined,
|
||||
InputBg: undefined,
|
||||
InputBorder: undefined,
|
||||
InputLabel: undefined,
|
||||
HeaderBg: undefined,
|
||||
HeaderBorder: undefined,
|
||||
HeaderLabel: undefined,
|
||||
FooterBg: undefined,
|
||||
FooterBorder: undefined,
|
||||
FooterLabel: undefined,
|
||||
GapLine: undefined,
|
||||
Nth: undefined,
|
||||
Nomatch: undefined,
|
||||
}
|
||||
@@ -930,7 +1074,7 @@ func init() {
|
||||
CurrentMatch: undefined,
|
||||
Spinner: undefined,
|
||||
Info: undefined,
|
||||
Cursor: undefined,
|
||||
Pointer: undefined,
|
||||
Marker: undefined,
|
||||
Header: undefined,
|
||||
Footer: undefined,
|
||||
@@ -943,6 +1087,7 @@ func init() {
|
||||
PreviewFg: undefined,
|
||||
PreviewBg: undefined,
|
||||
Gutter: undefined,
|
||||
AltGutter: undefined,
|
||||
PreviewBorder: undefined,
|
||||
PreviewScrollbar: undefined,
|
||||
PreviewLabel: undefined,
|
||||
@@ -980,7 +1125,7 @@ func init() {
|
||||
CurrentMatch: ColorAttr{colBrightGreen, AttrUndefined},
|
||||
Spinner: ColorAttr{colGreen, AttrUndefined},
|
||||
Info: ColorAttr{colYellow, AttrUndefined},
|
||||
Cursor: ColorAttr{colRed, AttrUndefined},
|
||||
Pointer: ColorAttr{colRed, AttrUndefined},
|
||||
Marker: ColorAttr{colMagenta, AttrUndefined},
|
||||
Header: ColorAttr{colCyan, AttrUndefined},
|
||||
Footer: ColorAttr{colCyan, AttrUndefined},
|
||||
@@ -991,6 +1136,7 @@ func init() {
|
||||
PreviewFg: undefined,
|
||||
PreviewBg: undefined,
|
||||
Gutter: undefined,
|
||||
AltGutter: undefined,
|
||||
PreviewBorder: undefined,
|
||||
PreviewScrollbar: undefined,
|
||||
PreviewLabel: undefined,
|
||||
@@ -1030,7 +1176,7 @@ func init() {
|
||||
CurrentMatch: ColorAttr{151, AttrUndefined},
|
||||
Spinner: ColorAttr{148, AttrUndefined},
|
||||
Info: ColorAttr{144, AttrUndefined},
|
||||
Cursor: ColorAttr{161, AttrUndefined},
|
||||
Pointer: ColorAttr{161, AttrUndefined},
|
||||
Marker: ColorAttr{168, AttrUndefined},
|
||||
Header: ColorAttr{109, AttrUndefined},
|
||||
Footer: ColorAttr{109, AttrUndefined},
|
||||
@@ -1041,6 +1187,7 @@ func init() {
|
||||
PreviewFg: undefined,
|
||||
PreviewBg: undefined,
|
||||
Gutter: undefined,
|
||||
AltGutter: undefined,
|
||||
PreviewBorder: undefined,
|
||||
PreviewScrollbar: undefined,
|
||||
PreviewLabel: undefined,
|
||||
@@ -1080,7 +1227,7 @@ func init() {
|
||||
CurrentMatch: ColorAttr{23, AttrUndefined},
|
||||
Spinner: ColorAttr{65, AttrUndefined},
|
||||
Info: ColorAttr{101, AttrUndefined},
|
||||
Cursor: ColorAttr{161, AttrUndefined},
|
||||
Pointer: ColorAttr{161, AttrUndefined},
|
||||
Marker: ColorAttr{168, AttrUndefined},
|
||||
Header: ColorAttr{31, AttrUndefined},
|
||||
Footer: ColorAttr{31, AttrUndefined},
|
||||
@@ -1091,6 +1238,7 @@ func init() {
|
||||
PreviewFg: undefined,
|
||||
PreviewBg: undefined,
|
||||
Gutter: undefined,
|
||||
AltGutter: undefined,
|
||||
PreviewBorder: undefined,
|
||||
PreviewScrollbar: undefined,
|
||||
PreviewLabel: undefined,
|
||||
@@ -1113,7 +1261,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 {
|
||||
theme.Bg = ColorAttr{colBlack, AttrUndefined}
|
||||
}
|
||||
@@ -1130,7 +1278,7 @@ func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlac
|
||||
theme.CurrentMatch = boldify(theme.CurrentMatch)
|
||||
theme.Prompt = boldify(theme.Prompt)
|
||||
theme.Input = boldify(theme.Input)
|
||||
theme.Cursor = boldify(theme.Cursor)
|
||||
theme.Pointer = boldify(theme.Pointer)
|
||||
theme.Spinner = boldify(theme.Spinner)
|
||||
}
|
||||
|
||||
@@ -1154,13 +1302,19 @@ func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlac
|
||||
match.Attr = Underline
|
||||
}
|
||||
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
|
||||
current := theme.Current
|
||||
if !baseTheme.Colored && current.IsUndefined() {
|
||||
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
|
||||
if !baseTheme.Colored && currentMatch.IsUndefined() {
|
||||
currentMatch.Attr |= Reverse | Underline
|
||||
@@ -1168,7 +1322,7 @@ func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlac
|
||||
theme.CurrentMatch = o(baseTheme.CurrentMatch, currentMatch)
|
||||
theme.Spinner = o(baseTheme.Spinner, theme.Spinner)
|
||||
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.Header = o(baseTheme.Header, theme.Header)
|
||||
theme.Footer = o(baseTheme.Footer, theme.Footer)
|
||||
@@ -1185,10 +1339,8 @@ func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlac
|
||||
scrollbarDefined := theme.Scrollbar != undefined
|
||||
previewBorderDefined := theme.PreviewBorder != undefined
|
||||
|
||||
// These colors are not defined in the base themes
|
||||
theme.ListFg = o(theme.Fg, theme.ListFg)
|
||||
theme.ListBg = o(theme.Bg, theme.ListBg)
|
||||
theme.SelectedFg = o(theme.ListFg, theme.SelectedFg)
|
||||
theme.NthSelectedAttr = theme.SelectedFg.Attr
|
||||
theme.SelectedFg = theme.ListFg.Merge(theme.SelectedFg)
|
||||
theme.SelectedBg = o(theme.ListBg, theme.SelectedBg)
|
||||
theme.SelectedMatch = o(theme.Match, theme.SelectedMatch)
|
||||
|
||||
@@ -1208,6 +1360,7 @@ func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlac
|
||||
gutter.Attr = Dim
|
||||
}
|
||||
theme.Gutter = o(theme.DarkBg, gutter)
|
||||
theme.AltGutter = o(theme.Gutter, theme.AltGutter)
|
||||
theme.PreviewFg = o(theme.Fg, theme.PreviewFg)
|
||||
theme.PreviewBg = o(theme.Bg, theme.PreviewBg)
|
||||
theme.PreviewLabel = o(theme.BorderLabel, theme.PreviewLabel)
|
||||
@@ -1242,11 +1395,22 @@ func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlac
|
||||
} else {
|
||||
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.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)
|
||||
|
||||
if theme.Nomatch.IsUndefined() {
|
||||
@@ -1261,7 +1425,7 @@ func initPalette(theme *ColorTheme) {
|
||||
if fg.Color == colDefault && (fg.Attr&Reverse) > 0 {
|
||||
bg.Color = colDefault
|
||||
}
|
||||
return ColorPair{fg.Color, bg.Color, fg.Attr}
|
||||
return ColorPair{fg.Color, bg.Color, colDefault, fg.Attr}
|
||||
}
|
||||
blank := theme.ListFg
|
||||
blank.Attr = AttrRegular
|
||||
@@ -1274,9 +1438,11 @@ func initPalette(theme *ColorTheme) {
|
||||
ColDisabled = pair(theme.Disabled, theme.InputBg)
|
||||
ColMatch = pair(theme.Match, theme.ListBg)
|
||||
ColSelectedMatch = pair(theme.SelectedMatch, theme.SelectedBg)
|
||||
ColCursor = pair(theme.Cursor, theme.Gutter)
|
||||
ColCursorEmpty = pair(blank, theme.Gutter)
|
||||
ColCursorEmptyChar = pair(theme.Gutter, theme.ListBg)
|
||||
ColPointer = pair(theme.Pointer, theme.Gutter)
|
||||
ColPointerEmpty = pair(blank, theme.Gutter)
|
||||
ColPointerEmptyChar = pair(theme.Gutter, theme.ListBg)
|
||||
ColAltPointerEmpty = pair(blank, theme.AltGutter)
|
||||
ColAltPointerEmptyChar = pair(theme.AltGutter, theme.ListBg)
|
||||
if theme.SelectedBg.Color != theme.ListBg.Color {
|
||||
ColMarker = pair(theme.Marker, theme.SelectedBg)
|
||||
} else {
|
||||
@@ -1284,8 +1450,8 @@ func initPalette(theme *ColorTheme) {
|
||||
}
|
||||
ColCurrent = pair(theme.Current, theme.DarkBg)
|
||||
ColCurrentMatch = pair(theme.CurrentMatch, theme.DarkBg)
|
||||
ColCurrentCursor = pair(theme.Cursor, theme.DarkBg)
|
||||
ColCurrentCursorEmpty = pair(blank, theme.DarkBg)
|
||||
ColCurrentPointer = pair(theme.Pointer, theme.DarkBg)
|
||||
ColCurrentPointerEmpty = pair(blank, theme.DarkBg)
|
||||
ColCurrentMarker = pair(theme.Marker, theme.DarkBg)
|
||||
ColCurrentSelectedEmpty = pair(blank, theme.DarkBg)
|
||||
ColSpinner = pair(theme.Spinner, theme.InputBg)
|
||||
@@ -1315,3 +1481,46 @@ func initPalette(theme *ColorTheme) {
|
||||
func runeWidth(r rune) int {
|
||||
return uniseg.StringWidth(string(r))
|
||||
}
|
||||
|
||||
// WrappedLine represents a single visual line after character-level wrapping.
|
||||
type WrappedLine struct {
|
||||
Text string
|
||||
DisplayWidth int
|
||||
}
|
||||
|
||||
// WrapLine splits a single line (no embedded \n) into visual lines
|
||||
// that fit within initialMax columns. Character-level wrapping only.
|
||||
func WrapLine(input string, prefixLength int, initialMax int, tabstop int, wrapSignWidth int) []WrappedLine {
|
||||
lines := []WrappedLine{}
|
||||
width := 0
|
||||
line := ""
|
||||
gr := uniseg.NewGraphemes(input)
|
||||
maxWidth := initialMax
|
||||
contMax := max(1, initialMax-wrapSignWidth)
|
||||
for gr.Next() {
|
||||
rs := gr.Runes()
|
||||
str := string(rs)
|
||||
var w int
|
||||
if len(rs) == 1 && rs[0] == '\t' {
|
||||
w = tabstop - (prefixLength+width)%tabstop
|
||||
str = strings.Repeat(" ", w)
|
||||
} else if rs[0] == '\r' {
|
||||
w++
|
||||
} else {
|
||||
w = uniseg.StringWidth(str)
|
||||
}
|
||||
width += w
|
||||
|
||||
if prefixLength+width <= maxWidth {
|
||||
line += str
|
||||
} else {
|
||||
lines = append(lines, WrappedLine{string(line), width - w})
|
||||
line = str
|
||||
prefixLength = 0
|
||||
width = w
|
||||
maxWidth = contMax
|
||||
}
|
||||
}
|
||||
lines = append(lines, WrappedLine{string(line), width})
|
||||
return lines
|
||||
}
|
||||
|
||||
@@ -2,6 +2,46 @@ package tui
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestWrapLine(t *testing.T) {
|
||||
// Basic wrapping
|
||||
lines := WrapLine("hello world", 0, 7, 8, 2)
|
||||
if len(lines) != 2 || lines[0].Text != "hello w" || lines[1].Text != "orld" {
|
||||
t.Errorf("Basic wrap: %v", lines)
|
||||
}
|
||||
|
||||
// Exact fit - no wrapping needed
|
||||
lines = WrapLine("hello", 0, 5, 8, 2)
|
||||
if len(lines) != 1 || lines[0].Text != "hello" || lines[0].DisplayWidth != 5 {
|
||||
t.Errorf("Exact fit: %v", lines)
|
||||
}
|
||||
|
||||
// With prefix length
|
||||
lines = WrapLine("hello", 3, 5, 8, 2)
|
||||
if len(lines) != 2 || lines[0].Text != "he" || lines[1].Text != "llo" {
|
||||
t.Errorf("Prefix length: %v", lines)
|
||||
}
|
||||
|
||||
// Empty string
|
||||
lines = WrapLine("", 0, 10, 8, 2)
|
||||
if len(lines) != 1 || lines[0].Text != "" || lines[0].DisplayWidth != 0 {
|
||||
t.Errorf("Empty string: %v", lines)
|
||||
}
|
||||
|
||||
// Continuation lines account for wrapSignWidth
|
||||
lines = WrapLine("abcdefghij", 0, 5, 8, 2)
|
||||
// First line: "abcde" (5 chars fit in width 5)
|
||||
// Continuation max: 5-2=3, so "fgh" then "ij"
|
||||
if len(lines) != 3 || lines[0].Text != "abcde" || lines[1].Text != "fgh" || lines[2].Text != "ij" {
|
||||
t.Errorf("Continuation: %v", lines)
|
||||
}
|
||||
|
||||
// Tab expansion
|
||||
lines = WrapLine("\there", 0, 10, 4, 2)
|
||||
if len(lines) != 1 || lines[0].DisplayWidth != 8 {
|
||||
t.Errorf("Tab: %v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHexToColor(t *testing.T) {
|
||||
assert := func(expr string, r, g, b int) {
|
||||
color := HexToColor(expr)
|
||||
|
||||
+15
-2
@@ -187,7 +187,7 @@ func (chars *Chars) TrailingWhitespaces() int {
|
||||
func (chars *Chars) TrimTrailingWhitespaces(maxIndex int) {
|
||||
whitespaces := chars.TrailingWhitespaces()
|
||||
end := len(chars.slice) - whitespaces
|
||||
chars.slice = chars.slice[0:Max(end, maxIndex)]
|
||||
chars.slice = chars.slice[0:max(end, maxIndex)]
|
||||
}
|
||||
|
||||
func (chars *Chars) TrimSuffix(runes []rune) {
|
||||
@@ -249,7 +249,7 @@ func (chars *Chars) Prepend(prefix string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (chars *Chars) Lines(multiLine bool, maxLines int, wrapCols int, wrapSignWidth int, tabstop int) ([][]rune, bool) {
|
||||
func (chars *Chars) Lines(multiLine bool, maxLines int, wrapCols int, wrapSignWidth int, tabstop int, wrapWord bool) ([][]rune, bool) {
|
||||
text := make([]rune, chars.Length())
|
||||
copy(text, chars.ToRunes())
|
||||
|
||||
@@ -307,6 +307,19 @@ func (chars *Chars) Lines(multiLine bool, maxLines int, wrapCols int, wrapSignWi
|
||||
if overflowIdx == 0 {
|
||||
overflowIdx = 1
|
||||
}
|
||||
if wrapWord {
|
||||
// Find last space/tab at or before overflowIdx
|
||||
breakIdx := -1
|
||||
for k := overflowIdx; k > 0; k-- {
|
||||
if line[k-1] == ' ' || line[k-1] == '\t' {
|
||||
breakIdx = k
|
||||
break
|
||||
}
|
||||
}
|
||||
if breakIdx > 0 {
|
||||
overflowIdx = breakIdx
|
||||
}
|
||||
}
|
||||
if len(wrapped) >= maxLines {
|
||||
return wrapped, true
|
||||
}
|
||||
|
||||
+49
-1
@@ -51,7 +51,7 @@ func TestTrimLength(t *testing.T) {
|
||||
func TestCharsLines(t *testing.T) {
|
||||
chars := ToChars([]byte("abcdef\n가나다\n\tdef"))
|
||||
check := func(multiLine bool, maxLines int, wrapCols int, wrapSignWidth int, tabstop int, expectedNumLines int, expectedOverflow bool) {
|
||||
lines, overflow := chars.Lines(multiLine, maxLines, wrapCols, wrapSignWidth, tabstop)
|
||||
lines, overflow := chars.Lines(multiLine, maxLines, wrapCols, wrapSignWidth, tabstop, false)
|
||||
fmt.Println(lines, overflow)
|
||||
if len(lines) != expectedNumLines || overflow != expectedOverflow {
|
||||
t.Errorf("Invalid result: %d %v (expected %d %v)", len(lines), overflow, expectedNumLines, expectedOverflow)
|
||||
@@ -81,3 +81,51 @@ func TestCharsLines(t *testing.T) {
|
||||
// With wrap sign (3 + 2) and no multi-line
|
||||
check(false, 100, 3, 2, 1, 13, false)
|
||||
}
|
||||
|
||||
func TestCharsLinesWrapWord(t *testing.T) {
|
||||
// "hello world foo bar" with width 12 should break at word boundaries
|
||||
chars := ToChars([]byte("hello world foo bar"))
|
||||
lines, overflow := chars.Lines(false, 100, 12, 0, 8, true)
|
||||
// "hello world " (12) | "foo bar" (7)
|
||||
if len(lines) != 2 || overflow {
|
||||
t.Errorf("Expected 2 lines, got %d (overflow: %v): %v", len(lines), overflow, lines)
|
||||
}
|
||||
if string(lines[0]) != "hello world " {
|
||||
t.Errorf("Expected first line 'hello world ', got %q", string(lines[0]))
|
||||
}
|
||||
if string(lines[1]) != "foo bar" {
|
||||
t.Errorf("Expected second line 'foo bar', got %q", string(lines[1]))
|
||||
}
|
||||
|
||||
// No word boundary: a single long word falls back to character wrap
|
||||
chars2 := ToChars([]byte("abcdefghijklmnop"))
|
||||
lines2, _ := chars2.Lines(false, 100, 10, 0, 8, true)
|
||||
if len(lines2) != 2 {
|
||||
t.Errorf("Expected 2 lines for long word, got %d: %v", len(lines2), lines2)
|
||||
}
|
||||
if string(lines2[0]) != "abcdefghij" {
|
||||
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
|
||||
chars4 := ToChars([]byte("hello world"))
|
||||
lines4, _ := chars4.Lines(false, 100, 8, 0, 8, false)
|
||||
if len(lines4) != 2 {
|
||||
t.Errorf("Expected 2 lines with wrapWord=false, got %d: %v", len(lines4), lines4)
|
||||
}
|
||||
if string(lines4[0]) != "hello wo" {
|
||||
t.Errorf("Expected first line 'hello wo', got %q", string(lines4[0]))
|
||||
}
|
||||
}
|
||||
|
||||
+10
-63
@@ -1,11 +1,11 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"math"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mattn/go-isatty"
|
||||
"github.com/rivo/uniseg"
|
||||
@@ -18,8 +18,13 @@ func StringWidth(s string) int {
|
||||
|
||||
// RunesWidth returns runes width
|
||||
func RunesWidth(runes []rune, prefixWidth int, tabstop int, limit int) (int, int) {
|
||||
return StringsWidth(string(runes), prefixWidth, tabstop, limit)
|
||||
}
|
||||
|
||||
// StringsWidth returns the width of the string
|
||||
func StringsWidth(str string, prefixWidth int, tabstop int, limit int) (int, int) {
|
||||
width := 0
|
||||
gr := uniseg.NewGraphemes(string(runes))
|
||||
gr := uniseg.NewGraphemes(str)
|
||||
idx := 0
|
||||
for gr.Next() {
|
||||
rs := gr.Runes()
|
||||
@@ -55,54 +60,8 @@ func Truncate(input string, limit int) ([]rune, int) {
|
||||
return runes, width
|
||||
}
|
||||
|
||||
// Max returns the largest integer
|
||||
func Max(first int, second int) int {
|
||||
if first >= second {
|
||||
return first
|
||||
}
|
||||
return second
|
||||
}
|
||||
|
||||
// Max16 returns the largest integer
|
||||
func Max16(first int16, second int16) int16 {
|
||||
if first >= second {
|
||||
return first
|
||||
}
|
||||
return second
|
||||
}
|
||||
|
||||
// Max32 returns the largest 32-bit integer
|
||||
func Max32(first int32, second int32) int32 {
|
||||
if first > second {
|
||||
return first
|
||||
}
|
||||
return second
|
||||
}
|
||||
|
||||
// Min returns the smallest integer
|
||||
func Min(first int, second int) int {
|
||||
if first <= second {
|
||||
return first
|
||||
}
|
||||
return second
|
||||
}
|
||||
|
||||
// Min32 returns the smallest 32-bit integer
|
||||
func Min32(first int32, second int32) int32 {
|
||||
if first <= second {
|
||||
return first
|
||||
}
|
||||
return second
|
||||
}
|
||||
|
||||
// Constrain32 limits the given 32-bit integer with the upper and lower bounds
|
||||
func Constrain32(val int32, min int32, max int32) int32 {
|
||||
return Max32(Min32(val, max), min)
|
||||
}
|
||||
|
||||
// Constrain limits the given integer with the upper and lower bounds
|
||||
func Constrain(val int, min int, max int) int {
|
||||
return Max(Min(val, max), min)
|
||||
func Constrain[T cmp.Ordered](val, minimum, maximum T) T {
|
||||
return max(min(val, maximum), minimum)
|
||||
}
|
||||
|
||||
func AsUint16(val int) uint16 {
|
||||
@@ -114,18 +73,6 @@ func AsUint16(val int) uint16 {
|
||||
return uint16(val)
|
||||
}
|
||||
|
||||
// DurWithin limits the given time.Duration with the upper and lower bounds
|
||||
func DurWithin(
|
||||
val time.Duration, min time.Duration, max time.Duration) time.Duration {
|
||||
if val < min {
|
||||
return min
|
||||
}
|
||||
if val > max {
|
||||
return max
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
// IsTty returns true if the file is a terminal
|
||||
func IsTty(file *os.File) bool {
|
||||
fd := file.Fd()
|
||||
@@ -197,7 +144,7 @@ func CompareVersions(v1, v2 string) int {
|
||||
return n
|
||||
}
|
||||
|
||||
for i := 0; i < Max(len(parts1), len(parts2)); i++ {
|
||||
for i := 0; i < max(len(parts1), len(parts2)); i++ {
|
||||
var p1, p2 int
|
||||
if i < len(parts1) {
|
||||
p1 = atoi(parts1[i])
|
||||
|
||||
@@ -4,72 +4,8 @@ import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMax(t *testing.T) {
|
||||
if Max(10, 1) != 10 {
|
||||
t.Error("Expected", 10)
|
||||
}
|
||||
if Max(-2, 5) != 5 {
|
||||
t.Error("Expected", 5)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMax16(t *testing.T) {
|
||||
if Max16(10, 1) != 10 {
|
||||
t.Error("Expected", 10)
|
||||
}
|
||||
if Max16(-2, 5) != 5 {
|
||||
t.Error("Expected", 5)
|
||||
}
|
||||
if Max16(math.MaxInt16, 0) != math.MaxInt16 {
|
||||
t.Error("Expected", math.MaxInt16)
|
||||
}
|
||||
if Max16(0, math.MinInt16) != 0 {
|
||||
t.Error("Expected", 0)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMax32(t *testing.T) {
|
||||
if Max32(10, 1) != 10 {
|
||||
t.Error("Expected", 10)
|
||||
}
|
||||
if Max32(-2, 5) != 5 {
|
||||
t.Error("Expected", 5)
|
||||
}
|
||||
if Max32(math.MaxInt32, 0) != math.MaxInt32 {
|
||||
t.Error("Expected", math.MaxInt32)
|
||||
}
|
||||
if Max32(0, math.MinInt32) != 0 {
|
||||
t.Error("Expected", 0)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMin(t *testing.T) {
|
||||
if Min(10, 1) != 1 {
|
||||
t.Error("Expected", 1)
|
||||
}
|
||||
if Min(-2, 5) != -2 {
|
||||
t.Error("Expected", -2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMin32(t *testing.T) {
|
||||
if Min32(10, 1) != 1 {
|
||||
t.Error("Expected", 1)
|
||||
}
|
||||
if Min32(-2, 5) != -2 {
|
||||
t.Error("Expected", -2)
|
||||
}
|
||||
if Min32(math.MaxInt32, 0) != 0 {
|
||||
t.Error("Expected", 0)
|
||||
}
|
||||
if Min32(0, math.MinInt32) != math.MinInt32 {
|
||||
t.Error("Expected", math.MinInt32)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConstrain(t *testing.T) {
|
||||
if Constrain(-3, -1, 3) != -1 {
|
||||
t.Error("Expected", -1)
|
||||
@@ -83,22 +19,6 @@ func TestConstrain(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConstrain32(t *testing.T) {
|
||||
if Constrain32(-3, -1, 3) != -1 {
|
||||
t.Error("Expected", -1)
|
||||
}
|
||||
if Constrain32(2, -1, 3) != 2 {
|
||||
t.Error("Expected", 2)
|
||||
}
|
||||
|
||||
if Constrain32(5, -1, 3) != 3 {
|
||||
t.Error("Expected", 3)
|
||||
}
|
||||
if Constrain32(0, math.MinInt32, math.MaxInt32) != 0 {
|
||||
t.Error("Expected", 0)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsUint16(t *testing.T) {
|
||||
if AsUint16(5) != 5 {
|
||||
t.Error("Expected", 5)
|
||||
@@ -120,18 +40,6 @@ func TestAsUint16(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurWithIn(t *testing.T) {
|
||||
if DurWithin(time.Duration(5), time.Duration(1), time.Duration(8)) != time.Duration(5) {
|
||||
t.Error("Expected", time.Duration(0))
|
||||
}
|
||||
if DurWithin(time.Duration(0)*time.Second, time.Second, time.Duration(3)*time.Second) != time.Second {
|
||||
t.Error("Expected", time.Second)
|
||||
}
|
||||
if DurWithin(time.Duration(10)*time.Second, time.Duration(0), time.Second) != time.Second {
|
||||
t.Error("Expected", time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnce(t *testing.T) {
|
||||
o := Once(false)
|
||||
if o() {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/junegunn/go-shellwords"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
@@ -20,8 +21,8 @@ type Executor struct {
|
||||
|
||||
func NewExecutor(withShell string) *Executor {
|
||||
shell := os.Getenv("SHELL")
|
||||
args := strings.Fields(withShell)
|
||||
if len(args) > 0 {
|
||||
args, err := shellwords.Parse(withShell)
|
||||
if err == nil && len(args) > 0 {
|
||||
shell = args[0]
|
||||
args = args[1:]
|
||||
} else {
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
type shellType int
|
||||
@@ -19,6 +21,7 @@ const (
|
||||
shellTypeUnknown shellType = iota
|
||||
shellTypeCmd
|
||||
shellTypePowerShell
|
||||
shellTypePwsh
|
||||
)
|
||||
|
||||
var escapeRegex = regexp.MustCompile(`[&|<>()^%!"]`)
|
||||
@@ -46,7 +49,10 @@ func NewExecutor(withShell string) *Executor {
|
||||
} else if strings.HasPrefix(basename, "cmd") {
|
||||
shellType = shellTypeCmd
|
||||
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
|
||||
args = []string{"-NoProfile", "-Command"}
|
||||
} else {
|
||||
@@ -56,8 +62,12 @@ func NewExecutor(withShell string) *Executor {
|
||||
}
|
||||
|
||||
// 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,
|
||||
// but it is left as is now because no adverse effect has been observed.
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
if x.shellType == shellTypeCmd {
|
||||
cmd = exec.Command(shell)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
HideWindow: false,
|
||||
CmdLine: fmt.Sprintf(`%s "%s"`, strings.Join(x.args, " "), command),
|
||||
CreationFlags: 0,
|
||||
CreationFlags: creationFlags,
|
||||
}
|
||||
} 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{
|
||||
HideWindow: false,
|
||||
CreationFlags: 0,
|
||||
CreationFlags: creationFlags,
|
||||
}
|
||||
}
|
||||
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"
|
||||
*/
|
||||
return escapeArg(entry)
|
||||
case shellTypePowerShell:
|
||||
case shellTypePowerShell, shellTypePwsh:
|
||||
escaped := strings.ReplaceAll(entry, `"`, `\"`)
|
||||
return "'" + strings.ReplaceAll(escaped, "'", "''") + "'"
|
||||
default:
|
||||
@@ -166,6 +188,21 @@ func (x *Executor) QuoteEntry(entry string) string {
|
||||
|
||||
// KillCommand kills the process for the given command
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package fzf
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
func runZellij(args []string, opts *Options) (int, error) {
|
||||
argStr, dir := popupArgStr(args, opts)
|
||||
|
||||
zellijArgs := []string{
|
||||
"run", "--floating", "--close-on-exit", "--block-until-exit",
|
||||
"--cwd", dir,
|
||||
}
|
||||
if !opts.Tmux.border {
|
||||
zellijArgs = append(zellijArgs, "--borderless", "true")
|
||||
}
|
||||
switch opts.Tmux.position {
|
||||
case posUp:
|
||||
zellijArgs = append(zellijArgs, "-y", "0")
|
||||
case posDown:
|
||||
zellijArgs = append(zellijArgs, "-y", "9999")
|
||||
case posLeft:
|
||||
zellijArgs = append(zellijArgs, "-x", "0")
|
||||
case posRight:
|
||||
zellijArgs = append(zellijArgs, "-x", "9999")
|
||||
case posCenter:
|
||||
// Zellij centers floating panes by default
|
||||
}
|
||||
zellijArgs = append(zellijArgs, "--width", opts.Tmux.width.String())
|
||||
zellijArgs = append(zellijArgs, "--height", opts.Tmux.height.String())
|
||||
zellijArgs = append(zellijArgs, "--")
|
||||
|
||||
return runProxy(argStr, func(temp string, needBash bool) (*exec.Cmd, error) {
|
||||
sh, err := sh(needBash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
zellijArgs = append(zellijArgs, sh, temp)
|
||||
return exec.Command("zellij", zellijArgs...), nil
|
||||
}, opts, true)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
# Unset fzf variables
|
||||
set -e FZF_DEFAULT_COMMAND FZF_DEFAULT_OPTS FZF_DEFAULT_OPTS_FILE FZF_TMUX FZF_TMUX_OPTS
|
||||
set -e FZF_CTRL_T_COMMAND FZF_CTRL_T_OPTS FZF_ALT_C_COMMAND FZF_ALT_C_OPTS FZF_CTRL_R_OPTS
|
||||
set -e FZF_API_KEY
|
||||
# Unset completion-specific variables
|
||||
set -e FZF_COMPLETION_OPTS FZF_EXPANSION_OPTS
|
||||
|
||||
set -gx FZF_DEFAULT_OPTS "--no-scrollbar --pointer '>' --marker '>'"
|
||||
set -gx fish_history fzf_test
|
||||
|
||||
# Add fzf to PATH
|
||||
fish_add_path <%= BASE %>/bin
|
||||
|
||||
# Source key bindings and completion
|
||||
source "<%= BASE %>/shell/key-bindings.fish"
|
||||
source "<%= BASE %>/shell/completion.fish"
|
||||
+159
-7
@@ -11,6 +11,7 @@ require 'net/http'
|
||||
require 'json'
|
||||
|
||||
TEMPLATE = File.read(File.expand_path('common.sh', __dir__))
|
||||
FISH_TEMPLATE = File.read(File.expand_path('common.fish', __dir__))
|
||||
UNSETS = %w[
|
||||
FZF_DEFAULT_COMMAND FZF_DEFAULT_OPTS
|
||||
FZF_TMUX FZF_TMUX_OPTS
|
||||
@@ -66,7 +67,48 @@ class Shell
|
||||
end
|
||||
|
||||
def fish
|
||||
"unset #{UNSETS.join(' ')}; rm -f ~/.local/share/fish/fzf_test_history; FZF_DEFAULT_OPTS=\"--no-scrollbar --pointer '>' --marker '>'\" fish_history=fzf_test fish"
|
||||
@fish ||=
|
||||
begin
|
||||
confdir = '/tmp/fzf-fish'
|
||||
FileUtils.rm_rf(confdir)
|
||||
FileUtils.mkdir_p("#{confdir}/fish/conf.d")
|
||||
File.open("#{confdir}/fish/conf.d/fzf.fish", 'w') do |f|
|
||||
f.puts ERB.new(FISH_TEMPLATE).result(binding)
|
||||
end
|
||||
"rm -f ~/.local/share/fish/fzf_test_history; XDG_CONFIG_HOME=#{confdir} fish"
|
||||
end
|
||||
end
|
||||
|
||||
def nushell
|
||||
@nushell ||=
|
||||
begin
|
||||
xdg_home = '/tmp/fzf-nushell-xdg'
|
||||
config_dir = "#{xdg_home}/nushell"
|
||||
FileUtils.rm_rf(xdg_home)
|
||||
FileUtils.mkdir_p(config_dir)
|
||||
|
||||
# Write env.nu to set up PATH and unset FZF variables
|
||||
File.open("#{config_dir}/env.nu", 'w') do |f|
|
||||
f.puts "$env.PATH = ($env.PATH | split row (char esep) | prepend '#{BASE}/bin')"
|
||||
UNSETS.each do |var|
|
||||
f.puts "hide-env -i #{var}"
|
||||
end
|
||||
f.puts "$env.FZF_DEFAULT_OPTS = \"--no-scrollbar --pointer '>' --marker '>'\""
|
||||
f.puts '$env.config = ($env.config | upsert history { file_format: "plaintext", max_size: 100 })'
|
||||
end
|
||||
|
||||
# Write config.nu with minimal prompt
|
||||
File.open("#{config_dir}/config.nu", 'w') do |f|
|
||||
f.puts '$env.PROMPT_COMMAND = {|| "" }'
|
||||
f.puts '$env.PROMPT_INDICATOR = ""'
|
||||
f.puts '$env.PROMPT_COMMAND_RIGHT = {|| "" }'
|
||||
f.puts '$env.config = ($env.config | upsert show_banner false)'
|
||||
f.puts "source #{BASE}/shell/key-bindings.nu"
|
||||
f.puts "source #{BASE}/shell/completion.nu"
|
||||
end
|
||||
|
||||
"unset #{UNSETS.join(' ')}; env XDG_CONFIG_HOME=#{xdg_home} XDG_DATA_HOME=#{xdg_home}/../fzf-nushell-data nu --config #{config_dir}/config.nu --env-config #{config_dir}/env.nu"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -75,12 +117,31 @@ class Tmux
|
||||
attr_reader :win
|
||||
|
||||
def initialize(shell = :bash)
|
||||
@shell = shell
|
||||
@win = go(%W[new-window -d -P -F #I #{Shell.send(shell)}]).first
|
||||
go(%W[set-window-option -t #{@win} pane-base-index 0])
|
||||
return unless shell == :fish
|
||||
if shell == :fish
|
||||
send_keys 'function fish_prompt; end; clear', :Enter
|
||||
self.until(&:empty?)
|
||||
elsif shell == :nushell
|
||||
# Clear history from previous tests to avoid contamination
|
||||
FileUtils.rm_f('/tmp/fzf-nushell-xdg/nushell/history.txt')
|
||||
# Wait for nushell to be ready by polling with a marker command.
|
||||
# We use 'print "fzf-ready"' and check for a line that is exactly
|
||||
# 'fzf-ready' (not the command echo which includes 'print').
|
||||
retries = 0
|
||||
begin
|
||||
send_keys 'print "fzf-ready"', :Enter
|
||||
self.until { |lines| lines.any? { |l| l.strip == 'fzf-ready' } }
|
||||
rescue Minitest::Assertion
|
||||
retries += 1
|
||||
raise if retries > 5
|
||||
|
||||
send_keys 'function fish_prompt; end; clear', :Enter
|
||||
self.until(&:empty?)
|
||||
retry
|
||||
end
|
||||
send_keys 'clear', :Enter
|
||||
self.until(&:empty?)
|
||||
end
|
||||
end
|
||||
|
||||
def kill
|
||||
@@ -95,6 +156,23 @@ class Tmux
|
||||
go(%W[send-keys -t #{win}] + args.map(&:to_s))
|
||||
end
|
||||
|
||||
# Simulate a mouse click at the given 1-based column and row using the SGR mouse protocol
|
||||
# (xterm mouse mode 1006, which fzf enables). The escape sequence is injected as literal
|
||||
# keystrokes via tmux, and fzf parses it like a real terminal mouse event.
|
||||
#
|
||||
# tmux's own mouse handling intercepts these sequences when `set -g mouse on`, so we toggle
|
||||
# mouse off for the duration of the click and restore the previous state afterwards.
|
||||
def click(col, row, button: 0)
|
||||
prev = go(%w[show-options -gv mouse]).first
|
||||
go(%w[set-option -g mouse off])
|
||||
begin
|
||||
seq = "\e[<#{button};#{col};#{row}M\e[<#{button};#{col};#{row}m"
|
||||
go(%W[send-keys -t #{win} -l #{seq}])
|
||||
ensure
|
||||
go(%W[set-option -g mouse #{prev}]) if prev && !prev.empty?
|
||||
end
|
||||
end
|
||||
|
||||
def paste(str)
|
||||
system('tmux', 'setb', str, ';', 'pasteb', '-t', win, ';', 'send-keys', '-t', win, 'Enter')
|
||||
end
|
||||
@@ -103,6 +181,71 @@ class Tmux
|
||||
go(%W[capture-pane -p -J -t #{win}]).map(&:rstrip).reverse.drop_while(&:empty?).reverse
|
||||
end
|
||||
|
||||
# Raw pane capture with ANSI escape sequences preserved.
|
||||
def capture_ansi
|
||||
go(%W[capture-pane -p -J -e -t #{win}])
|
||||
end
|
||||
|
||||
# 3-bit ANSI bg code (40..47) -> color name used in --color options.
|
||||
BG_NAMES = %w[black red green yellow blue magenta cyan white].freeze
|
||||
|
||||
# Parse `tmux capture-pane -e` output into per-row bg ranges. Each row is an
|
||||
# array of [col_start, col_end, bg] tuples where bg is one of:
|
||||
# 'default'
|
||||
# 'red' / 'green' / 'blue' / ... (3-bit names)
|
||||
# 'bright-red' / ... (bright variants)
|
||||
# '256:<n>' (256-color fallback)
|
||||
# ANSI state persists across rows, matching real terminal behavior.
|
||||
def bg_ranges
|
||||
raw = go(%W[capture-pane -p -J -e -t #{win}])
|
||||
bg = 'default'
|
||||
raw.map do |row|
|
||||
cells = []
|
||||
i = 0
|
||||
len = row.length
|
||||
while i < len
|
||||
c = row[i]
|
||||
if c == "\e" && row[i + 1] == '['
|
||||
j = i + 2
|
||||
j += 1 while j < len && row[j] != 'm'
|
||||
parts = row[i + 2...j].split(';')
|
||||
k = 0
|
||||
while k < parts.length
|
||||
p = parts[k].to_i
|
||||
case p
|
||||
when 0, 49 then bg = 'default'
|
||||
when 40..47 then bg = BG_NAMES[p - 40]
|
||||
when 100..107 then bg = "bright-#{BG_NAMES[p - 100]}"
|
||||
when 48
|
||||
if parts[k + 1] == '5'
|
||||
bg = "256:#{parts[k + 2]}"
|
||||
k += 2
|
||||
elsif parts[k + 1] == '2'
|
||||
bg = "rgb:#{parts[k + 2]}:#{parts[k + 3]}:#{parts[k + 4]}"
|
||||
k += 4
|
||||
end
|
||||
end
|
||||
k += 1
|
||||
end
|
||||
i = j + 1
|
||||
else
|
||||
cells << bg
|
||||
i += 1
|
||||
end
|
||||
end
|
||||
ranges = []
|
||||
start = 0
|
||||
cells.each_with_index do |b, idx|
|
||||
if idx.positive? && b != cells[idx - 1]
|
||||
ranges << [start, idx - 1, cells[idx - 1]]
|
||||
start = idx
|
||||
end
|
||||
end
|
||||
ranges << [start, cells.length - 1, cells.last] unless cells.empty?
|
||||
ranges
|
||||
end
|
||||
end
|
||||
|
||||
def until(refresh = false, timeout: DEFAULT_TIMEOUT)
|
||||
lines = nil
|
||||
begin
|
||||
@@ -150,10 +293,19 @@ class Tmux
|
||||
def prepare
|
||||
tries = 0
|
||||
begin
|
||||
self.until(true) do |lines|
|
||||
if @shell == :nushell
|
||||
message = "Prepare[#{tries}]"
|
||||
send_keys ' ', 'C-u', :Enter, message, :Left, :Right
|
||||
lines[-1] == message
|
||||
send_keys 'C-u', 'C-l'
|
||||
sleep(0.2)
|
||||
send_keys ' ', 'C-u', :Enter, message
|
||||
self.until { |lines| lines[-1] == message }
|
||||
else
|
||||
self.until(true) do |lines|
|
||||
message = "Prepare[#{tries}]"
|
||||
send_keys ' ', 'C-u', :Enter, message, :Left, :Right
|
||||
sleep(0.15)
|
||||
lines[-1] == message
|
||||
end
|
||||
end
|
||||
rescue Minitest::Assertion
|
||||
(tries += 1) < 5 ? retry : raise
|
||||
|
||||
+663
-19
@@ -404,11 +404,11 @@ class TestCore < TestInteractive
|
||||
tmux.send_keys "seq 1 111 | #{fzf("-m +s --tac #{opt} -q11")}", :Enter
|
||||
tmux.until { |lines| assert_equal '> 111', lines[-3] }
|
||||
tmux.send_keys :Tab
|
||||
tmux.until { |lines| assert_equal ' 4/111 -S (1)', lines[-2] }
|
||||
tmux.until { |lines| assert_equal ' 4/111 (1) -S', lines[-2] }
|
||||
tmux.send_keys 'C-R'
|
||||
tmux.until { |lines| assert_equal '> 11', lines[-3] }
|
||||
tmux.send_keys :Tab
|
||||
tmux.until { |lines| assert_equal ' 4/111 +S (2)', lines[-2] }
|
||||
tmux.until { |lines| assert_equal ' 4/111 (2) +S', lines[-2] }
|
||||
tmux.send_keys :Enter
|
||||
assert_equal %w[111 11], fzf_output_lines
|
||||
end
|
||||
@@ -971,6 +971,24 @@ class TestCore < TestInteractive
|
||||
tmux.until { |lines| assert_includes lines[1], ' aabravo/aabravo' }
|
||||
end
|
||||
|
||||
def test_transform_put
|
||||
tmux.send_keys %(seq 1000 | #{FZF} --bind 'a:transform:echo put'), :Enter
|
||||
tmux.until { |lines| assert_equal 1000, lines.match_count }
|
||||
tmux.send_keys :a
|
||||
tmux.until { |lines| assert_equal '> a', lines.last }
|
||||
tmux.send_keys :b
|
||||
tmux.until { |lines| assert_equal '> ab', lines.last }
|
||||
end
|
||||
|
||||
# The async callback runs in a later iteration, but 'put' must still insert
|
||||
# the key that triggered the bg-transform (snapshot of the scheduling event).
|
||||
def test_bg_transform_put
|
||||
tmux.send_keys %(seq 1000 | #{FZF} --bind 'a:bg-transform:sleep 0.5; echo put'), :Enter
|
||||
tmux.until { |lines| assert_equal 1000, lines.match_count }
|
||||
tmux.send_keys 'ab'
|
||||
tmux.until { |lines| assert_equal '> ba', lines.last }
|
||||
end
|
||||
|
||||
def test_accept_non_empty
|
||||
tmux.send_keys %(seq 1000 | #{fzf('--print-query --bind enter:accept-non-empty')}), :Enter
|
||||
tmux.until { |lines| assert_equal 1000, lines.match_count }
|
||||
@@ -1190,8 +1208,18 @@ class TestCore < TestInteractive
|
||||
tmux.until { |lines| assert lines.any_include?('9999␊10000') }
|
||||
end
|
||||
|
||||
def test_freeze_left_tabstop
|
||||
writelines(%W[1\t2\t3])
|
||||
# With --freeze-left 1 and --tabstop=2:
|
||||
# Frozen left: "1" (width 1)
|
||||
# Middle starts with "\t" at prefix width 1, tabstop 2 → 1 space
|
||||
# Then "2" at column 2, next "\t" at column 3 → 1 space, then "3"
|
||||
tmux.send_keys %(cat #{tempname} | #{FZF} --tabstop=2 --freeze-left 1), :Enter
|
||||
tmux.until { |lines| assert_equal '> 1 2 3', lines[-3] }
|
||||
end
|
||||
|
||||
def test_freeze_left_keep_right
|
||||
tmux.send_keys %[seq 10000 | #{FZF} --read0 --delimiter "\n" --freeze-left 3 --keep-right --ellipsis XX --no-multi-line --bind space:toggle-multi-line], :Enter
|
||||
tmux.send_keys %(seq 10000 | #{FZF} --read0 --delimiter "\n" --freeze-left 3 --keep-right --ellipsis XX --no-multi-line --bind space:toggle-multi-line), :Enter
|
||||
tmux.until { |lines| assert_match(/^> 1␊2␊3XX.*10000␊$/, lines[-3]) }
|
||||
tmux.send_keys '5'
|
||||
tmux.until { |lines| assert_match(/^> 1␊2␊3␊4␊5␊.*XX$/, lines[-3]) }
|
||||
@@ -1202,21 +1230,21 @@ class TestCore < TestInteractive
|
||||
end
|
||||
|
||||
def test_freeze_left_and_right
|
||||
tmux.send_keys %[seq 10000 | tr "\n" ' ' | #{FZF} --freeze-left 3 --freeze-right 3 --ellipsis XX], :Enter
|
||||
tmux.send_keys %(seq 10000 | tr "\n" ' ' | #{FZF} --freeze-left 3 --freeze-right 3 --ellipsis XX), :Enter
|
||||
tmux.until { |lines| assert_match(/XX9998 9999 10000$/, lines[-3]) }
|
||||
tmux.send_keys "'1000"
|
||||
tmux.until { |lines| assert_match(/^> 1 2 3XX.*XX9998 9999 10000$/,lines[-3]) }
|
||||
tmux.until { |lines| assert_match(/^> 1 2 3XX.*XX9998 9999 10000$/, lines[-3]) }
|
||||
end
|
||||
|
||||
def test_freeze_left_and_right_delimiter
|
||||
tmux.send_keys %[seq 10000 | tr "\n" ' ' | sed 's/ / , /g' | #{FZF} --freeze-left 3 --freeze-right 3 --ellipsis XX --delimiter ' , '], :Enter
|
||||
tmux.send_keys %(seq 10000 | tr "\n" ' ' | sed 's/ / , /g' | #{FZF} --freeze-left 3 --freeze-right 3 --ellipsis XX --delimiter ' , '), :Enter
|
||||
tmux.until { |lines| assert_match(/XX, 9999 , 10000 ,$/, lines[-3]) }
|
||||
tmux.send_keys "'1000"
|
||||
tmux.until { |lines| assert_match(/^> 1 , 2 , 3 ,XX.*XX, 9999 , 10000 ,$/,lines[-3]) }
|
||||
tmux.until { |lines| assert_match(/^> 1 , 2 , 3 ,XX.*XX, 9999 , 10000 ,$/, lines[-3]) }
|
||||
end
|
||||
|
||||
def test_freeze_right_exceed_range
|
||||
tmux.send_keys %[seq 10000 | tr "\n" ' ' | #{FZF} --freeze-right 100000 --ellipsis XX], :Enter
|
||||
tmux.send_keys %(seq 10000 | tr "\n" ' ' | #{FZF} --freeze-right 100000 --ellipsis XX), :Enter
|
||||
['', "'1000"].each do |query|
|
||||
tmux.send_keys query
|
||||
tmux.until { |lines| assert lines.any_include?("> #{query}".strip) }
|
||||
@@ -1228,7 +1256,7 @@ class TestCore < TestInteractive
|
||||
end
|
||||
|
||||
def test_freeze_right_exceed_range_with_freeze_left
|
||||
tmux.send_keys %[seq 10000 | tr "\n" ' ' | #{FZF} --freeze-left 3 --freeze-right 100000 --ellipsis XX], :Enter
|
||||
tmux.send_keys %(seq 10000 | tr "\n" ' ' | #{FZF} --freeze-left 3 --freeze-right 100000 --ellipsis XX), :Enter
|
||||
tmux.until do |lines|
|
||||
assert_match(/^> 1 2 3XX.*9998 9999 10000$/, lines[-3])
|
||||
assert_equal(1, lines[-3].scan('XX').size)
|
||||
@@ -1241,7 +1269,7 @@ class TestCore < TestInteractive
|
||||
tmux.send_keys(*Array.new(6) { :a })
|
||||
tmux.until do |lines|
|
||||
assert_match(/> 777g+$/, lines[-3])
|
||||
assert_equal 1, lines.count { |l| l.end_with?('g') }
|
||||
assert_equal(1, lines.count { |l| l.end_with?('g') })
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1377,6 +1405,85 @@ class TestCore < TestInteractive
|
||||
tmux.until { |lines| assert_includes lines, '> 1' }
|
||||
end
|
||||
|
||||
def test_every_event
|
||||
tmux.send_keys %(seq 100 | fzf --bind 'every(0.2):transform-prompt(cat #{tempname})'), :Enter
|
||||
tmux.until { |lines| assert_equal 100, lines.match_count }
|
||||
# Trigger external state changes; the every() tick should pick them up.
|
||||
writelines(['AAA>'])
|
||||
tmux.until { |lines| assert_includes lines[-1], 'AAA>' }
|
||||
writelines(['BBB>'])
|
||||
tmux.until { |lines| assert_includes lines[-1], 'BBB>' }
|
||||
end
|
||||
|
||||
def test_every_event_multiple_independent_timers
|
||||
# Two timers with different durations should fire independently.
|
||||
fast = tempname + '.fast'
|
||||
slow = tempname + '.slow'
|
||||
FileUtils.rm_f(fast)
|
||||
FileUtils.rm_f(slow)
|
||||
tmux.send_keys %(seq 100 | fzf \\
|
||||
--bind 'every(0.1):execute-silent(printf . >> #{fast})' \\
|
||||
--bind 'every(0.5):execute-silent(printf . >> #{slow})'), :Enter
|
||||
tmux.until { |lines| assert_equal 100, lines.match_count }
|
||||
sleep(1.2)
|
||||
a = File.exist?(fast) ? File.size(fast) : 0
|
||||
b = File.exist?(slow) ? File.size(slow) : 0
|
||||
# Sanity: faster timer fired more times.
|
||||
assert_operator a, :>, b, "fast timer should fire more (#{a} vs #{b})"
|
||||
# Sanity: slow timer fired at least once.
|
||||
assert_operator b, :>=, 1, "slow timer should have fired at least once (#{b})"
|
||||
ensure
|
||||
FileUtils.rm_f(fast)
|
||||
FileUtils.rm_f(slow)
|
||||
end
|
||||
|
||||
def test_every_event_unbind
|
||||
tmux.send_keys %(seq 100 | fzf --bind 'every(0.1):transform-header(date +%S.%N)' --bind 'space:unbind(every(0.1))+change-header(STOPPED)'), :Enter
|
||||
tmux.until { |lines| assert_equal 100, lines.match_count }
|
||||
# Header should be ticking
|
||||
tmux.until { |lines| assert_match(/^ \d{2}\.\d+/, lines[-3]) }
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert_includes lines[-3], 'STOPPED' }
|
||||
sleep(0.4)
|
||||
# Header must stay STOPPED after the unbind
|
||||
assert_includes tmux.capture[-3], 'STOPPED'
|
||||
end
|
||||
|
||||
def test_fzf_idle_time_env
|
||||
# FZF_IDLE_TIME + FZF_IDLE_TIME_MS combined with every() implement idle-based behavior.
|
||||
tmux.send_keys %(seq 100 | fzf --bind 'every(0.2):transform-header(echo "s=$FZF_IDLE_TIME ms_ok=$((FZF_IDLE_TIME_MS / 1000 == FZF_IDLE_TIME))")'), :Enter
|
||||
tmux.until { |lines| assert_equal 100, lines.match_count }
|
||||
# Idle counter advances without any input; ms/1000 stays consistent with seconds.
|
||||
tmux.until { |lines| assert_includes lines[-3], 's=1 ms_ok=1' }
|
||||
tmux.until { |lines| assert_includes lines[-3], 's=2 ms_ok=1' }
|
||||
# Any keystroke resets the counter
|
||||
tmux.send_keys 'x'
|
||||
tmux.until { |lines| assert_includes lines[-3], 's=0 ms_ok=1' }
|
||||
tmux.send_keys :BSpace
|
||||
# And it advances again afterwards
|
||||
tmux.until { |lines| assert_includes lines[-3], 's=1 ms_ok=1' }
|
||||
end
|
||||
|
||||
def test_every_event_rejects_invalid_arg
|
||||
%w[every(0) every(-1) every(abc) every()].each do |spec|
|
||||
tmux.send_keys %(seq 1 | fzf --bind '#{spec}:abort' 2>&1; echo done=$?), :Enter
|
||||
tmux.until { |lines| assert(lines.any? { |l| l.include?('done=2') }) }
|
||||
tmux.send_keys 'clear', :Enter
|
||||
end
|
||||
end
|
||||
|
||||
def test_fzf_key_ignores_synthetic_events
|
||||
tmux.send_keys %(seq 100 | fzf --bind 'every(0.2):transform-prompt(echo "[$FZF_KEY]> ")'), :Enter
|
||||
tmux.until { |lines| assert_equal 100, lines.match_count }
|
||||
# No user input yet: prompt should show empty FZF_KEY
|
||||
tmux.until { |lines| assert_includes lines[-1], '[]>' }
|
||||
tmux.send_keys 'x'
|
||||
tmux.until { |lines| assert_includes lines[-1], '[x]>' }
|
||||
# every() ticks shouldn't overwrite FZF_KEY
|
||||
sleep(1)
|
||||
assert_includes tmux.capture[-1], '[x]>'
|
||||
end
|
||||
|
||||
def test_labels_center
|
||||
tmux.send_keys 'echo x | fzf --border --border-label foobar --preview : --preview-label barfoo --bind "space:change-border-label(foobarfoo)+change-preview-label(barfoobar),enter:transform-border-label(echo foo{}foo)+transform-preview-label(echo bar{}bar)"', :Enter
|
||||
tmux.until do
|
||||
@@ -1588,14 +1695,16 @@ class TestCore < TestInteractive
|
||||
end
|
||||
|
||||
def test_track_action
|
||||
tmux.send_keys "seq 1000 | #{FZF} --query 555 --bind t:track", :Enter
|
||||
tmux.send_keys "seq 1000 | #{FZF} --pointer x --query 555 --bind t:track,T:up+track", :Enter
|
||||
tmux.until do |lines|
|
||||
assert_equal 1, lines.match_count
|
||||
assert_includes lines, 'x 555'
|
||||
assert_includes lines, '> 555'
|
||||
end
|
||||
tmux.send_keys :BSpace
|
||||
tmux.until do |lines|
|
||||
assert_equal 28, lines.match_count
|
||||
assert_includes lines, 'x 55'
|
||||
assert_includes lines, '> 55'
|
||||
end
|
||||
tmux.send_keys :t
|
||||
@@ -1605,7 +1714,8 @@ class TestCore < TestInteractive
|
||||
tmux.send_keys :BSpace
|
||||
tmux.until do |lines|
|
||||
assert_equal 271, lines.match_count
|
||||
assert_includes lines, '> 55'
|
||||
assert_includes lines, 'x 55'
|
||||
assert_includes lines, '> 5'
|
||||
end
|
||||
|
||||
# Automatically disabled when the tracking item is no longer visible
|
||||
@@ -1617,16 +1727,263 @@ class TestCore < TestInteractive
|
||||
tmux.send_keys :BSpace
|
||||
tmux.until do |lines|
|
||||
assert_equal 271, lines.match_count
|
||||
assert_includes lines, 'x 52'
|
||||
assert_includes lines, '> 5'
|
||||
end
|
||||
tmux.send_keys :t
|
||||
tmux.until do |lines|
|
||||
assert_includes lines[-2], '+t'
|
||||
end
|
||||
|
||||
# Automatically disabled when the focus has moved
|
||||
tmux.send_keys :Up
|
||||
tmux.until do |lines|
|
||||
assert_includes lines, 'x 53'
|
||||
refute_includes lines[-2], '+t'
|
||||
end
|
||||
|
||||
# Should work even when combined with a focus moving actions
|
||||
tmux.send_keys 'T'
|
||||
tmux.until do |lines|
|
||||
assert_includes lines, 'x 54'
|
||||
assert_includes lines[-2], '+t'
|
||||
end
|
||||
|
||||
tmux.send_keys 'T'
|
||||
tmux.until do |lines|
|
||||
assert_includes lines, 'x 55'
|
||||
assert_includes lines[-2], '+t'
|
||||
end
|
||||
end
|
||||
|
||||
def test_track_nth_reload_whole_line
|
||||
# --track --id-nth .. should track by entire line across reloads
|
||||
tmux.send_keys "seq 1000 | #{FZF} --track --id-nth .. --bind 'ctrl-r:reload:seq 1000 | sort -R'", :Enter
|
||||
tmux.until { |lines| assert_equal 1000, lines.match_count }
|
||||
|
||||
# Move to item 555
|
||||
tmux.send_keys '555'
|
||||
tmux.until do |lines|
|
||||
assert_equal 1, lines.match_count
|
||||
assert_includes lines, '> 555'
|
||||
end
|
||||
tmux.send_keys :BSpace, :BSpace, :BSpace
|
||||
|
||||
# Reload with shuffled order - cursor should track "555"
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until do |lines|
|
||||
assert_equal 1000, lines.match_count
|
||||
assert_includes lines, '> 555'
|
||||
assert_includes lines[-2], '+T'
|
||||
refute_includes lines[-2], '+T*'
|
||||
end
|
||||
end
|
||||
|
||||
def test_track_nth_reload_field
|
||||
# --track --id-nth 1 should track by first field across reloads
|
||||
tmux.send_keys "printf '1 apple\\n2 banana\\n3 cherry\\n' | #{FZF} --track --id-nth 1 --bind 'ctrl-r:reload:printf \"1 apricot\\n2 blueberry\\n3 cranberry\\n\"'", :Enter
|
||||
tmux.until do |lines|
|
||||
assert_equal 3, lines.match_count
|
||||
assert_includes lines, '> 1 apple'
|
||||
end
|
||||
|
||||
# Move up to "2 banana"
|
||||
tmux.send_keys :Up
|
||||
tmux.until { |lines| assert_includes lines, '> 2 banana' }
|
||||
|
||||
# Reload - the second field changes, but first field "2" stays
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until do |lines|
|
||||
assert_equal 3, lines.match_count
|
||||
assert_includes lines, '> 2 blueberry'
|
||||
end
|
||||
end
|
||||
|
||||
def test_track_nth_reload_no_match
|
||||
# When tracked item is not found after reload, cursor stays at current position
|
||||
tmux.send_keys "printf 'alpha\\nbeta\\ngamma\\n' | #{FZF} --track --id-nth .. --bind 'ctrl-r:reload:printf \"delta\\nepsilon\\nzeta\\n\"'", :Enter
|
||||
tmux.until { |lines| assert_equal 3, lines.match_count }
|
||||
tmux.send_keys :Up
|
||||
tmux.until { |lines| assert_includes lines, '> beta' }
|
||||
|
||||
# Reload with completely different items - no match for "beta"
|
||||
# Cursor stays at the same position (second item)
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until do |lines|
|
||||
assert_equal 3, lines.match_count
|
||||
assert_includes lines, '> epsilon'
|
||||
refute_includes lines[-2], '+T*'
|
||||
end
|
||||
end
|
||||
|
||||
def test_track_nth_blocked_indicator
|
||||
# +T* should appear during reload and disappear when match is found
|
||||
tmux.send_keys "seq 100 | #{FZF} --track --id-nth .. --bind 'ctrl-r:reload:sleep 1; seq 100 | sort -R'", :Enter
|
||||
tmux.until do |lines|
|
||||
assert_equal 100, lines.match_count
|
||||
assert_includes lines[-2], '+T'
|
||||
end
|
||||
|
||||
# Trigger slow reload - should show +T* while blocked
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until { |lines| assert_includes lines[-2], '+T*' }
|
||||
|
||||
# After reload completes, +T* should clear back to +T
|
||||
tmux.until do |lines|
|
||||
assert_equal 100, lines.match_count
|
||||
assert_includes lines[-2], '+T'
|
||||
refute_includes lines[-2], '+T*'
|
||||
end
|
||||
end
|
||||
|
||||
def test_track_nth_abort_unblocks
|
||||
# Escape during track-blocked state should unblock, not quit
|
||||
tmux.send_keys "seq 100 | #{FZF} --track --id-nth .. --bind 'ctrl-r:reload:sleep 3; seq 100'", :Enter
|
||||
tmux.until do |lines|
|
||||
assert_equal 100, lines.match_count
|
||||
assert_includes lines[-2], '+T'
|
||||
end
|
||||
|
||||
# Trigger slow reload
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until { |lines| assert_includes lines[-2], '+T*' }
|
||||
|
||||
# Escape should unblock, not quit fzf
|
||||
tmux.send_keys :Escape
|
||||
tmux.until do |lines|
|
||||
assert_includes lines[-2], '+T'
|
||||
refute_includes lines[-2], '+T*'
|
||||
end
|
||||
end
|
||||
|
||||
def test_track_nth_reload_async_unblocks_early
|
||||
# With async reload, +T* should clear as soon as the match streams in,
|
||||
# even while loading is still in progress.
|
||||
# sleep 1 first so +T* is observable, then the match arrives, then more items after a delay.
|
||||
tmux.send_keys "seq 5 | #{FZF} --track --id-nth .. --bind 'ctrl-r:reload:sleep 1; echo 1; sleep 2; seq 2 10'", :Enter
|
||||
tmux.until do |lines|
|
||||
assert_equal 5, lines.match_count
|
||||
assert_includes lines, '> 1'
|
||||
end
|
||||
|
||||
# Trigger reload - blocked during initial sleep
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until { |lines| assert_includes lines[-2], '+T*' }
|
||||
# Match "1" arrives, unblocks before the remaining items load
|
||||
tmux.until do |lines|
|
||||
assert_equal 1, lines.match_count
|
||||
assert_includes lines, '> 1'
|
||||
assert_includes lines[-2], '+T'
|
||||
refute_includes lines[-2], '+T*'
|
||||
end
|
||||
end
|
||||
|
||||
def test_track_nth_reload_sync_blocks_until_complete
|
||||
# With reload-sync, +T* should stay until the entire stream is complete,
|
||||
# even though the match arrives early in the stream.
|
||||
tmux.send_keys "seq 5 | #{FZF} --track --id-nth .. --bind 'ctrl-r:reload-sync:sleep 1; echo 1; sleep 2; seq 2 10'", :Enter
|
||||
tmux.until do |lines|
|
||||
assert_equal 5, lines.match_count
|
||||
assert_includes lines, '> 1'
|
||||
end
|
||||
|
||||
# Trigger reload-sync - every observable state must be either:
|
||||
# 1. +T* (still blocked), or
|
||||
# 2. final state (count=10, +T without *)
|
||||
# Any other combination (e.g. unblocked while count < 10) is a bug.
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until do |lines|
|
||||
info = lines[-2]
|
||||
blocked = info&.include?('+T*')
|
||||
unless blocked
|
||||
raise "Unblocked before stream complete (count: #{lines.match_count})" if lines.match_count != 10
|
||||
|
||||
assert_includes info, '+T'
|
||||
assert_includes lines, '> 1'
|
||||
end
|
||||
!blocked
|
||||
end
|
||||
end
|
||||
|
||||
def test_track_nth_toggle_track_unblocks
|
||||
# toggle-track during track-blocked state should unblock and disable tracking
|
||||
tmux.send_keys "seq 100 | #{FZF} --track --id-nth .. --bind 'ctrl-r:reload:sleep 5; seq 100' --bind 'ctrl-t:toggle-track'", :Enter
|
||||
tmux.until do |lines|
|
||||
assert_equal 100, lines.match_count
|
||||
assert_includes lines[-2], '+T'
|
||||
end
|
||||
|
||||
# Trigger slow reload
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until { |lines| assert_includes lines[-2], '+T*' }
|
||||
|
||||
# toggle-track should unblock and disable tracking before reload completes
|
||||
tmux.send_keys 'C-t'
|
||||
tmux.until(timeout: 3) do |lines|
|
||||
refute_includes lines[-2], '+T'
|
||||
end
|
||||
end
|
||||
|
||||
def test_track_nth_reload_async_no_match
|
||||
# With async reload, when tracked item is not found, cursor stays at
|
||||
# current position after stream completes
|
||||
tmux.send_keys "printf 'alpha\\nbeta\\ngamma\\n' | #{FZF} --track --id-nth .. --bind 'ctrl-r:reload:sleep 1; printf \"delta\\nepsilon\\nzeta\\n\"'", :Enter
|
||||
tmux.until { |lines| assert_equal 3, lines.match_count }
|
||||
tmux.send_keys :Up
|
||||
tmux.until { |lines| assert_includes lines, '> beta' }
|
||||
|
||||
# Reload with completely different items - no match for "beta"
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until { |lines| assert_includes lines[-2], '+T*' }
|
||||
# After stream completes, unblocks with cursor at same position (second item)
|
||||
tmux.until do |lines|
|
||||
assert_equal 3, lines.match_count
|
||||
assert_includes lines, '> epsilon'
|
||||
refute_includes lines[-2], '+T*'
|
||||
end
|
||||
end
|
||||
|
||||
def test_track_action_with_id_nth
|
||||
# track-current with --id-nth should track by specified field
|
||||
tmux.send_keys "printf '1 apple\\n2 banana\\n3 cherry\\n' | #{FZF} --id-nth 1 --bind 'ctrl-t:track-current,ctrl-r:reload:printf \"1 apricot\\n2 blueberry\\n3 cranberry\\n\"'", :Enter
|
||||
tmux.until { |lines| assert_equal 3, lines.match_count }
|
||||
|
||||
# Move to "2 banana" and activate tracking
|
||||
tmux.send_keys :Up
|
||||
tmux.until { |lines| assert_includes lines, '> 2 banana' }
|
||||
tmux.send_keys 'C-t'
|
||||
tmux.until { |lines| assert_includes lines[-2], '+t' }
|
||||
|
||||
# Reload - should track by field "2"
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until do |lines|
|
||||
assert_equal 3, lines.match_count
|
||||
assert_includes lines, '> 2 blueberry'
|
||||
end
|
||||
end
|
||||
|
||||
def test_id_nth_preserve_multi_selection
|
||||
# --id-nth with --multi should preserve selections across reload-sync
|
||||
File.write(tempname, "1 apricot\n2 blueberry\n3 cranberry\n")
|
||||
tmux.send_keys "printf '1 apple\\n2 banana\\n3 cherry\\n' | #{fzf("--multi --id-nth 1 --bind 'ctrl-r:reload-sync:cat #{tempname}'")}", :Enter
|
||||
tmux.until { |lines| assert_equal 3, lines.match_count }
|
||||
|
||||
# Select first item (1 apple) and third item (3 cherry)
|
||||
tmux.send_keys :Tab
|
||||
tmux.send_keys :Up, :Up, :Tab
|
||||
tmux.until { |lines| assert_includes lines[-2], '(2)' }
|
||||
|
||||
# Reload - selections should be preserved by id-nth key
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until do |lines|
|
||||
assert_equal 3, lines.match_count
|
||||
assert_includes lines[-2], '(2)'
|
||||
assert(lines.any? { |l| l.include?('apricot') })
|
||||
end
|
||||
|
||||
# Accept and verify the correct items were preserved
|
||||
tmux.send_keys :Enter
|
||||
assert_equal ['1 apricot', '3 cranberry'], fzf_output_lines
|
||||
end
|
||||
|
||||
def test_one_and_zero
|
||||
@@ -1725,6 +2082,209 @@ class TestCore < TestInteractive
|
||||
end
|
||||
end
|
||||
|
||||
def test_change_with_nth
|
||||
input = [
|
||||
'foo bar baz',
|
||||
'aaa bbb ccc',
|
||||
'xxx yyy zzz'
|
||||
]
|
||||
writelines(input)
|
||||
# Start with field 1 only, cycle through fields, verify $FZF_WITH_NTH via prompt
|
||||
tmux.send_keys %(#{FZF} --with-nth 1 --bind 'space:change-with-nth(2|3|1),result:transform-prompt:echo "[$FZF_WITH_NTH]> "' < #{tempname}), :Enter
|
||||
tmux.until do |lines|
|
||||
assert_equal 3, lines.item_count
|
||||
assert lines.any_include?('[1]>')
|
||||
assert lines.any_include?('foo')
|
||||
refute lines.any_include?('bar')
|
||||
end
|
||||
tmux.send_keys :Space
|
||||
tmux.until do |lines|
|
||||
assert lines.any_include?('[2]>')
|
||||
assert lines.any_include?('bar')
|
||||
refute lines.any_include?('foo')
|
||||
end
|
||||
tmux.send_keys :Space
|
||||
tmux.until do |lines|
|
||||
assert lines.any_include?('[3]>')
|
||||
assert lines.any_include?('baz')
|
||||
refute lines.any_include?('bar')
|
||||
end
|
||||
tmux.send_keys :Space
|
||||
tmux.until do |lines|
|
||||
assert lines.any_include?('[1]>')
|
||||
assert lines.any_include?('foo')
|
||||
refute lines.any_include?('bar')
|
||||
end
|
||||
end
|
||||
|
||||
def test_change_with_nth_default
|
||||
# Empty value restores the default --with-nth
|
||||
tmux.send_keys %(echo -e 'a b c\nd e f' | #{FZF} --with-nth 1 --bind 'space:change-with-nth(2|)'), :Enter
|
||||
tmux.until do |lines|
|
||||
assert_equal 2, lines.item_count
|
||||
assert lines.any_include?('a')
|
||||
refute lines.any_include?('b')
|
||||
end
|
||||
# Switch to field 2
|
||||
tmux.send_keys :Space
|
||||
tmux.until do |lines|
|
||||
assert lines.any_include?('b')
|
||||
refute lines.any_include?('a')
|
||||
end
|
||||
# Empty restores default (field 1)
|
||||
tmux.send_keys :Space
|
||||
tmux.until do |lines|
|
||||
assert lines.any_include?('a')
|
||||
refute lines.any_include?('b')
|
||||
end
|
||||
end
|
||||
|
||||
def test_transform_with_nth_search
|
||||
input = [
|
||||
'alpha bravo charlie',
|
||||
'delta echo foxtrot',
|
||||
'golf hotel india'
|
||||
]
|
||||
writelines(input)
|
||||
tmux.send_keys %(#{FZF} --with-nth 1 --bind 'space:transform-with-nth(echo 2)' -q '^bravo$' < #{tempname}), :Enter
|
||||
tmux.until do |lines|
|
||||
assert_equal 0, lines.match_count
|
||||
end
|
||||
tmux.send_keys :Space
|
||||
tmux.until do |lines|
|
||||
assert_equal 1, lines.match_count
|
||||
end
|
||||
end
|
||||
|
||||
def test_bg_transform_with_nth_output
|
||||
tmux.send_keys %(echo -e 'a b c\nd e f' | #{FZF} --with-nth 2 --bind 'space:bg-transform-with-nth(echo 3)'), :Enter
|
||||
tmux.until do |lines|
|
||||
assert_equal 2, lines.item_count
|
||||
assert lines.any_include?('b')
|
||||
end
|
||||
tmux.send_keys :Space
|
||||
tmux.until do |lines|
|
||||
assert lines.any_include?('c')
|
||||
refute lines.any_include?('b')
|
||||
end
|
||||
tmux.send_keys :Enter
|
||||
tmux.until { |lines| assert lines.any_include?('a b c') || lines.any_include?('d e f') }
|
||||
end
|
||||
|
||||
# Regression: actions emitted by bg-transform must affect the iteration that
|
||||
# processes the async result, not the (no-longer-active) iteration that
|
||||
# scheduled the transform. Covers reload (newCommand) and exclude (denylist).
|
||||
def test_bg_transform_action_output
|
||||
tmux.send_keys %(seq 5 | #{FZF} --bind 'a:bg-transform(echo reload:seq 10 20),b:bg-transform(echo exclude)'), :Enter
|
||||
tmux.until { |lines| assert_equal 5, lines.item_count }
|
||||
tmux.send_keys :a
|
||||
tmux.until do |lines|
|
||||
assert_equal 11, lines.match_count
|
||||
assert_includes lines, '> 10'
|
||||
end
|
||||
tmux.send_keys :b
|
||||
tmux.until do |lines|
|
||||
assert_equal 10, lines.match_count
|
||||
assert_includes lines, '> 11'
|
||||
end
|
||||
end
|
||||
|
||||
def test_change_with_nth_search
|
||||
input = [
|
||||
'alpha bravo charlie',
|
||||
'delta echo foxtrot',
|
||||
'golf hotel india'
|
||||
]
|
||||
writelines(input)
|
||||
tmux.send_keys %(#{FZF} --with-nth 1 --bind 'space:change-with-nth(2)' -q '^bravo$' < #{tempname}), :Enter
|
||||
tmux.until do |lines|
|
||||
assert_equal 0, lines.match_count
|
||||
end
|
||||
tmux.send_keys :Space
|
||||
tmux.until do |lines|
|
||||
assert_equal 1, lines.match_count
|
||||
end
|
||||
end
|
||||
|
||||
def test_change_with_nth_output
|
||||
tmux.send_keys %(echo -e 'a b c\nd e f' | #{FZF} --with-nth 2 --bind 'space:change-with-nth(3)'), :Enter
|
||||
tmux.until do |lines|
|
||||
assert_equal 2, lines.item_count
|
||||
assert lines.any_include?('b')
|
||||
end
|
||||
tmux.send_keys :Space
|
||||
tmux.until do |lines|
|
||||
assert lines.any_include?('c')
|
||||
refute lines.any_include?('b')
|
||||
end
|
||||
tmux.send_keys :Enter
|
||||
tmux.until { |lines| assert lines.any_include?('a b c') || lines.any_include?('d e f') }
|
||||
end
|
||||
|
||||
def test_change_with_nth_selection
|
||||
# Items: field1 has unique values, field2 has 'match' or 'miss'
|
||||
input = [
|
||||
'one match x',
|
||||
'two miss y',
|
||||
'three match z'
|
||||
]
|
||||
writelines(input)
|
||||
# Start showing field 2 (match/miss), query 'match', select all matches, then switch to field 3
|
||||
tmux.send_keys %(#{FZF} --with-nth 2 --multi --bind 'ctrl-a:select-all,space:change-with-nth(3)' -q match < #{tempname}), :Enter
|
||||
tmux.until do |lines|
|
||||
assert_equal 2, lines.match_count
|
||||
end
|
||||
# Select all matching items
|
||||
tmux.send_keys 'C-a'
|
||||
tmux.until do |lines|
|
||||
assert lines.any_include?('(2)')
|
||||
end
|
||||
# Now change with-nth to field 3; 'x' and 'z' don't contain 'match'
|
||||
tmux.send_keys :Space
|
||||
tmux.until do |lines|
|
||||
assert_equal 0, lines.match_count
|
||||
# Selections of non-matching items should be cleared
|
||||
assert lines.any_include?('(0)')
|
||||
end
|
||||
end
|
||||
|
||||
def test_change_with_nth_multiline
|
||||
# Each item has 3 lines: "N-a\nN-b\nN-c"
|
||||
# --with-nth 1 shows 1 line per item, --with-nth 1..3 shows 3 lines per item
|
||||
tmux.send_keys %(seq 20 | xargs -I{} printf '{}-a\\n{}-b\\n{}-c\\0' | #{FZF} --read0 --delimiter "\n" --with-nth 1 --bind 'space:change-with-nth(1..3|1)' --no-sort), :Enter
|
||||
tmux.until do |lines|
|
||||
assert_equal 20, lines.item_count
|
||||
assert lines.any_include?('1-a')
|
||||
refute lines.any_include?('1-b')
|
||||
end
|
||||
# Expand to 3 lines per item
|
||||
tmux.send_keys :Space
|
||||
tmux.until do |lines|
|
||||
assert lines.any_include?('1-a')
|
||||
assert lines.any_include?('1-b')
|
||||
assert lines.any_include?('1-c')
|
||||
end
|
||||
# Scroll down a few items
|
||||
5.times { tmux.send_keys :Down }
|
||||
tmux.until do |lines|
|
||||
assert lines.any_include?('6-a')
|
||||
assert lines.any_include?('6-b')
|
||||
assert lines.any_include?('6-c')
|
||||
end
|
||||
# Collapse back to 1 line per item
|
||||
tmux.send_keys :Space
|
||||
tmux.until do |lines|
|
||||
assert lines.any_include?('6-a')
|
||||
refute lines.any_include?('6-b')
|
||||
end
|
||||
# Scroll down more after collapse
|
||||
5.times { tmux.send_keys :Down }
|
||||
tmux.until do |lines|
|
||||
assert lines.any_include?('11-a')
|
||||
refute lines.any_include?('11-b')
|
||||
end
|
||||
end
|
||||
|
||||
def test_env_vars
|
||||
def env_vars
|
||||
return {} unless File.exist?(tempname)
|
||||
@@ -1735,7 +2295,7 @@ class TestCore < TestInteractive
|
||||
end
|
||||
end
|
||||
|
||||
tmux.send_keys %(seq 100 | #{FZF} --multi --reverse --preview-window 0 --preview 'env | grep ^FZF_ | sort > #{tempname}' --no-input --bind enter:show-input+refresh-preview,space:disable-search+refresh-preview), :Enter
|
||||
tmux.send_keys %({ echo foo; seq 100; } | #{FZF} --header-lines 1 --multi --reverse --preview-window 0 --preview 'env | grep ^FZF_ | sort > #{tempname}' --no-input --bind enter:show-input+refresh-preview,space:disable-search+refresh-preview), :Enter
|
||||
expected = {
|
||||
FZF_DIRECTION: 'down',
|
||||
FZF_TOTAL_COUNT: '100',
|
||||
@@ -1744,6 +2304,7 @@ class TestCore < TestInteractive
|
||||
FZF_ACTION: 'start',
|
||||
FZF_KEY: '',
|
||||
FZF_POS: '1',
|
||||
FZF_CURRENT_ITEM: '1',
|
||||
FZF_QUERY: '',
|
||||
FZF_POINTER: '>',
|
||||
FZF_PROMPT: '> ',
|
||||
@@ -1759,12 +2320,12 @@ class TestCore < TestInteractive
|
||||
end
|
||||
tmux.send_keys :Tab, :Tab
|
||||
tmux.until do
|
||||
expected.merge!(FZF_ACTION: 'toggle-down', FZF_KEY: 'tab', FZF_POS: '3', FZF_SELECT_COUNT: '2')
|
||||
expected.merge!(FZF_ACTION: 'toggle-down', FZF_KEY: 'tab', FZF_POS: '3', FZF_CURRENT_ITEM: '3', FZF_SELECT_COUNT: '2')
|
||||
assert_equal expected, env_vars.slice(*expected.keys)
|
||||
end
|
||||
tmux.send_keys '99'
|
||||
tmux.until do
|
||||
expected.merge!(FZF_ACTION: 'char', FZF_KEY: '9', FZF_QUERY: '99', FZF_MATCH_COUNT: '1', FZF_POS: '1')
|
||||
expected.merge!(FZF_ACTION: 'char', FZF_KEY: '9', FZF_QUERY: '99', FZF_MATCH_COUNT: '1', FZF_POS: '1', FZF_CURRENT_ITEM: '99')
|
||||
assert_equal expected, env_vars.slice(*expected.keys)
|
||||
end
|
||||
tmux.send_keys :Space
|
||||
@@ -1880,13 +2441,13 @@ class TestCore < TestInteractive
|
||||
tmux.send_keys %(echo "foo ,bar,baz" | #{FZF} -d, --accept-nth 2,2,1,3,1 --sync --bind start:accept > #{tempname}), :Enter
|
||||
wait do
|
||||
assert_path_exists tempname
|
||||
# Last delimiter and the whitespaces are removed
|
||||
assert_equal ['bar,bar,foo ,bazfoo'], File.readlines(tempname, chomp: true)
|
||||
# Last delimiter is removed
|
||||
assert_equal ['bar,bar,foo ,bazfoo '], File.readlines(tempname, chomp: true)
|
||||
end
|
||||
end
|
||||
|
||||
def test_accept_nth_regex_delimiter
|
||||
tmux.send_keys %(echo "foo :,:bar,baz" | #{FZF} --delimiter='[:,]+' --accept-nth 2,2,1,3,1 --sync --bind start:accept > #{tempname}), :Enter
|
||||
tmux.send_keys %(echo "foo :,:bar,baz" | #{FZF} --delimiter=' *[:,]+ *' --accept-nth 2,2,1,3,1 --sync --bind start:accept > #{tempname}), :Enter
|
||||
wait do
|
||||
assert_path_exists tempname
|
||||
# Last delimiter and the whitespaces are removed
|
||||
@@ -1904,7 +2465,7 @@ class TestCore < TestInteractive
|
||||
end
|
||||
|
||||
def test_accept_nth_template
|
||||
tmux.send_keys %(echo "foo ,bar,baz" | #{FZF} -d, --accept-nth '[{n}] 1st: {1}, 3rd: {3}, 2nd: {2}' --sync --bind start:accept > #{tempname}), :Enter
|
||||
tmux.send_keys %(echo "foo ,bar,baz" | #{FZF} -d " *, *" --accept-nth '[{n}] 1st: {1}, 3rd: {3}, 2nd: {2}' --sync --bind start:accept > #{tempname}), :Enter
|
||||
wait do
|
||||
assert_path_exists tempname
|
||||
# Last delimiter and the whitespaces are removed
|
||||
@@ -2155,4 +2716,87 @@ class TestCore < TestInteractive
|
||||
assert_equal 1, it.match_count
|
||||
end
|
||||
end
|
||||
|
||||
def test_change_header_lines
|
||||
tmux.send_keys %(seq 10 | #{FZF} --header-lines 3 --bind 'space:change-header-lines(5),enter:transform-header-lines(echo 1)'), :Enter
|
||||
tmux.until do |lines|
|
||||
assert_equal 7, lines.item_count
|
||||
assert lines.any_include?('> 4')
|
||||
end
|
||||
tmux.send_keys :Space
|
||||
tmux.until do |lines|
|
||||
assert_equal 5, lines.item_count
|
||||
assert lines.any_include?('> 6')
|
||||
end
|
||||
tmux.send_keys :Enter
|
||||
tmux.until do |lines|
|
||||
assert_equal 9, lines.item_count
|
||||
assert lines.any_include?('> 6')
|
||||
end
|
||||
end
|
||||
|
||||
def test_change_header_lines_to_zero
|
||||
tmux.send_keys %(seq 5 | #{FZF} --header-lines 3 --bind 'space:bg-transform-header-lines(echo 0)'), :Enter
|
||||
tmux.until do |lines|
|
||||
assert_equal 2, lines.item_count
|
||||
assert lines.any_include?('> 4')
|
||||
end
|
||||
tmux.send_keys :Space
|
||||
tmux.until do |lines|
|
||||
assert_equal 5, lines.item_count
|
||||
# All items are now in the list, cursor stays on item 4
|
||||
assert lines.any_include?('> 4')
|
||||
end
|
||||
end
|
||||
|
||||
def test_change_header_lines_deselect
|
||||
# Selected items that become part of the header should be deselected
|
||||
tmux.send_keys %(seq 10 | #{FZF} --multi --header-lines 0 --bind 'space:change-header-lines(3),enter:change-header-lines(1)'), :Enter
|
||||
tmux.until do |lines|
|
||||
assert_equal 10, lines.item_count
|
||||
assert lines.any_include?('> 1')
|
||||
end
|
||||
# Select items 1, 2, 3 (these will become header lines)
|
||||
tmux.send_keys :BTab, :BTab, :BTab
|
||||
tmux.until { |lines| assert_equal 3, lines.select_count }
|
||||
# Also select item 4 (this should remain selected)
|
||||
tmux.send_keys :BTab
|
||||
tmux.until { |lines| assert_equal 4, lines.select_count }
|
||||
# Change header-lines to 3: items 1, 2, 3 become headers and should be deselected
|
||||
tmux.send_keys :Space
|
||||
tmux.until do |lines|
|
||||
assert_equal 7, lines.item_count
|
||||
assert_equal 1, lines.select_count
|
||||
assert lines.any_include?('> 5')
|
||||
end
|
||||
# Change header-lines to 1
|
||||
tmux.send_keys :Enter
|
||||
tmux.until do |lines|
|
||||
assert_equal 9, lines.item_count
|
||||
assert_equal 1, lines.select_count
|
||||
assert lines.any_include?('> 5')
|
||||
end
|
||||
end
|
||||
|
||||
def test_change_header_lines_reverse
|
||||
tmux.send_keys %(seq 10 | #{FZF} --header-lines 2 --reverse --bind 'space:change-header-lines(4)'), :Enter
|
||||
tmux.until do |lines|
|
||||
assert_equal 8, lines.item_count
|
||||
assert lines.any_include?('> 3')
|
||||
end
|
||||
tmux.send_keys :Space
|
||||
tmux.until do |lines|
|
||||
assert_equal 6, lines.item_count
|
||||
assert lines.any_include?('> 5')
|
||||
end
|
||||
end
|
||||
|
||||
def test_zero_width_characters
|
||||
tmux.send_keys %(for i in {1..1000}; do string+="a̱$i"; printf '\\e[43m%s\\e[0m\\n' "$string"; done | #{FZF} --ansi --query a500 --ellipsis XX), :Enter
|
||||
tmux.until do |lines|
|
||||
assert_equal 981, lines.match_count
|
||||
assert_match(/^> XX.*a̱500/, lines[-3])
|
||||
assert(lines.reverse.drop(5).all? { it.match?(/^ XX.*a̱500.*XX/) })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -312,4 +312,41 @@ class TestFilter < TestBase
|
||||
assert_equal expected, result
|
||||
end
|
||||
end
|
||||
|
||||
def test_accept_nth
|
||||
# Single field selection
|
||||
assert_equal 'three', `echo 'one two three' | #{FZF} -d' ' --with-nth 1 --accept-nth -1 -f one`.chomp
|
||||
|
||||
# Multiple field selection
|
||||
writelines(['ID001:John:Developer', 'ID002:Jane:Manager', 'ID003:Bob:Designer'])
|
||||
assert_equal 'ID001', `#{FZF} -d: --with-nth 2 --accept-nth 1 -f John < #{tempname}`.chomp
|
||||
assert_equal 'ID002:Manager', `#{FZF} -d: --with-nth 2 --accept-nth 1,3 -f Jane < #{tempname}`.chomp
|
||||
|
||||
# Test with different delimiters
|
||||
writelines(['emp001 Alice Engineering', 'emp002 Bob Marketing'])
|
||||
assert_equal 'emp001', `#{FZF} -d' ' --with-nth 2 --accept-nth 1 -f Alice < #{tempname}`.chomp
|
||||
end
|
||||
|
||||
def test_header_lines_filter
|
||||
assert_equal %w[4 5 6 7 8 9 10],
|
||||
`seq 10 | #{FZF} --header-lines 3 -f ""`.lines(chomp: true)
|
||||
assert_equal %w[5],
|
||||
`seq 10 | #{FZF} --header-lines 3 -f 5`.lines(chomp: true)
|
||||
# Header items should not be matched
|
||||
assert_empty `seq 10 | #{FZF} --header-lines 3 -f "^1$"`.lines(chomp: true)
|
||||
end
|
||||
|
||||
def test_header_lines_filter_with_nth
|
||||
writelines(%w[a:1 b:2 c:3 d:4 e:5])
|
||||
assert_equal %w[c:3 d:4 e:5],
|
||||
`#{FZF} --header-lines 2 -d: --with-nth 2 -f "" < #{tempname}`.lines(chomp: true)
|
||||
assert_equal %w[d:4],
|
||||
`#{FZF} --header-lines 2 -d: --with-nth 2 -f 4 < #{tempname}`.lines(chomp: true)
|
||||
end
|
||||
|
||||
def test_header_lines_all_headers
|
||||
# When all lines are header lines, no results
|
||||
assert_empty `seq 3 | #{FZF} --header-lines 10 -f ""`.chomp
|
||||
assert_equal 1, $CHILD_STATUS.exitstatus
|
||||
end
|
||||
end
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user