Compare commits

..
1 Commits
109 changed files with 2445 additions and 11098 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
root = true root = true
[*.{sh,bash,fish}] [*.{sh,bash}]
indent_style = space indent_style = space
indent_size = 2 indent_size = 2
simplify = true simplify = true
-1
View File
@@ -1 +0,0 @@
* @junegunn
-17
View File
@@ -1,17 +0,0 @@
## 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.
-64
View File
@@ -1,64 +0,0 @@
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
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7 uses: actions/checkout@v5
with: with:
fetch-depth: 0 fetch-depth: 0
+2 -2
View File
@@ -9,6 +9,6 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: 'Checkout Repository' - name: 'Checkout Repository'
uses: actions/checkout@v7 uses: actions/checkout@v5
- name: 'Dependency Review' - name: 'Dependency Review'
uses: actions/dependency-review-action@v5 uses: actions/dependency-review-action@v4
-17
View File
@@ -1,17 +0,0 @@
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
+4 -14
View File
@@ -5,7 +5,7 @@ on:
push: push:
branches: [ master, devel ] branches: [ master, devel ]
pull_request: pull_request:
branches: [ master, devel ] branches: [ master ]
workflow_dispatch: workflow_dispatch:
permissions: permissions:
@@ -18,7 +18,7 @@ jobs:
build: build:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v5
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -28,17 +28,12 @@ jobs:
go-version: "1.23" go-version: "1.23"
- name: Setup Ruby - name: Setup Ruby
uses: ruby/setup-ruby@9eb537ca036ebaed86729dcb9309076e4c5c3b74 # v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: 3.4.6 ruby-version: 3.4.6
- name: Install packages - name: Install packages
run: | run: sudo apt-get install --yes zsh fish tmux shfmt
sudo install -d -m 0755 /etc/apt/keyrings
wget -qO- https://apt.fury.io/nushell/gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/fury-nushell.gpg
echo "deb [signed-by=/etc/apt/keyrings/fury-nushell.gpg] https://apt.fury.io/nushell/ /" | sudo tee /etc/apt/sources.list.d/fury-nushell.list
sudo apt-get update
sudo apt-get install --yes zsh fish tmux shfmt nushell
- name: Install Ruby gems - name: Install Ruby gems
run: bundle install run: bundle install
@@ -49,10 +44,5 @@ jobs:
- name: Unit test - name: Unit test
run: make test run: make test
- name: Fuzz test
run: |
go test ./src/algo/ -fuzz=FuzzIndexByteTwo -fuzztime=5s
go test ./src/algo/ -fuzz=FuzzLastIndexByteTwo -fuzztime=5s
- name: Integration test - name: Integration test
run: make install && ./install --all && tmux new-session -d && ruby test/runner.rb --verbose run: make install && ./install --all && tmux new-session -d && ruby test/runner.rb --verbose
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
build: build:
runs-on: macos-latest runs-on: macos-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v5
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -25,7 +25,7 @@ jobs:
go-version: "1.23" go-version: "1.23"
- name: Setup Ruby - name: Setup Ruby
uses: ruby/setup-ruby@9eb537ca036ebaed86729dcb9309076e4c5c3b74 # v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: 3.0.0 ruby-version: 3.0.0
-76
View File
@@ -1,76 +0,0 @@
name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
version:
description: 'Version to validate (e.g. 0.73.0).'
type: string
required: true
permissions:
contents: write
jobs:
release:
runs-on: macos-latest
environment: release
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-go@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 }}
+24
View File
@@ -0,0 +1,24 @@
---
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: '.'
+2 -2
View File
@@ -6,5 +6,5 @@ jobs:
name: Spell Check with Typos name: Spell Check with Typos
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v5
- uses: crate-ci/typos@685eb3d55be2f85191e8c84acb9f44d7756f84ab # v1.29.4 - uses: crate-ci/typos@v1.29.4
+1 -8
View File
@@ -2,20 +2,13 @@ name: Publish to Winget
on: on:
release: release:
types: [released] types: [released]
workflow_dispatch:
inputs:
release-tag:
description: 'Release tag to submit (e.g. v0.73.1)'
required: true
type: string
jobs: jobs:
publish: publish:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: vedantmgoyal2009/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2 - uses: vedantmgoyal2009/winget-releaser@v2
with: with:
identifier: junegunn.fzf identifier: junegunn.fzf
release-tag: ${{ inputs.release-tag || github.event.release.tag_name }}
installers-regex: '-windows_(armv7|arm64|amd64)\.zip$' installers-regex: '-windows_(armv7|arm64|amd64)\.zip$'
token: ${{ secrets.WINGET_TOKEN }} token: ${{ secrets.WINGET_TOKEN }}
+4 -9
View File
@@ -22,7 +22,6 @@ builds:
- loong64 - loong64
- ppc64le - ppc64le
- s390x - s390x
- riscv64
goarm: goarm:
- "5" - "5"
- "6" - "6"
@@ -40,8 +39,6 @@ builds:
goarch: arm64 goarch: arm64
- goos: openbsd - goos: openbsd
goarch: arm64 goarch: arm64
- goos: openbsd
goarch: riscv64
- goos: android - goos: android
goarch: amd64 goarch: amd64
- goos: android - goos: android
@@ -85,14 +82,12 @@ notarize:
archives: archives:
- name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}" - name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
ids: builds:
- fzf - fzf
formats: format: tar.gz
- tar.gz
format_overrides: format_overrides:
- goos: windows - goos: windows
formats: format: zip
- zip
files: files:
- non-existent* - non-existent*
@@ -104,7 +99,7 @@ release:
name_template: '{{ .Version }}' name_template: '{{ .Version }}'
snapshot: snapshot:
version_template: "{{ .Version }}-devel" name_template: "{{ .Version }}-devel"
changelog: changelog:
sort: asc sort: asc
+8 -8
View File
@@ -309,16 +309,16 @@ I know it's a lot to digest, let's try to break down the code.
available color options. available color options.
- The value of `--preview-window` option consists of 5 components delimited - The value of `--preview-window` option consists of 5 components delimited
by `,` by `,`
1. `up` -- Position of the preview window 1. `up` Position of the preview window
1. `60%` -- Size of the preview window 1. `60%` Size of the preview window
1. `border-bottom` -- Preview window border only on the bottom side 1. `border-bottom` Preview window border only on the bottom side
1. `+{2}+3/3` -- Scroll offset of the preview contents 1. `+{2}+3/3` Scroll offset of the preview contents
1. `~3` -- Fixed header 1. `~3` Fixed header
- Let's break down the latter two. We want to display the bat output in the - Let's break down the latter two. We want to display the bat output in the
preview window with a certain scroll offset so that the matching line is preview window with a certain scroll offset so that the matching line is
positioned near the center of the preview window. positioned near the center of the preview window.
- `+{2}` -- The base offset is extracted from the second token - `+{2}` The base offset is extracted from the second token
- `+3` -- We add 3 lines to the base offset to compensate for the header - `+3` We add 3 lines to the base offset to compensate for the header
part of `bat` output part of `bat` output
- ``` - ```
───────┬────────────────────────────────────────────────────────── ───────┬──────────────────────────────────────────────────────────
@@ -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 " RG_PREFIX="rg --column --line-number --no-heading --color=always --smart-case "
INITIAL_QUERY="${*:-}" INITIAL_QUERY="${*:-}"
fzf --ansi --disabled --query "$INITIAL_QUERY" \ fzf --ansi --disabled --query "$INITIAL_QUERY" \
--bind "start:reload:$RG_PREFIX {q} || true" \ --bind "start:reload:$RG_PREFIX {q}" \
--bind "change:reload:sleep 0.1; $RG_PREFIX {q} || true" \ --bind "change:reload:sleep 0.1; $RG_PREFIX {q} || true" \
--delimiter : \ --delimiter : \
--preview 'bat --color=always {1} --highlight-line {2}' \ --preview 'bat --color=always {1} --highlight-line {2}' \
-253
View File
@@ -1,259 +1,6 @@
CHANGELOG CHANGELOG
========= =========
0.74.0
------
_Release highlights: https://junegunn.github.io/fzf/releases/0.74.0/_
- On tmux 3.7 or above, `--popup` starts fzf in a floating pane instead of a popup (#4850)
- Unlike a popup, a floating pane is not modal; you can switch to other panes and windows while fzf is running, move and resize the pane with the mouse, zoom it to fullscreen, and use copy-mode in it
- A floating pane always has a native border, which is what makes the pane movable and resizable, so `border-native` is implied
- A popup is used instead when a border style is explicitly specified with `--border`, so that the fzf-drawn border is the only border shown (`none` and `line` are treated as no border)
```sh
fzf --popup --border
```
- `--border-label` is set as the title of the floating pane, and is displayed on the border if `pane-border-status` is enabled in tmux
```sh
fzf --popup --border-label ' fzf '
```
- On Zellij, `--popup` uses the native border by default, consistent with tmux, so that the pane can be moved and resized with the mouse; fzf draws its own border when a border style is explicitly specified with `--border`
- `--border-label` is set as the name of the pane, displayed on the native border
- Added `result-final` event, a variant of `result` that is not triggered while the input stream is still open (#4835)
- Use it for one-shot, per-query actions that would otherwise re-fire on every intermediate snapshot during loading
```sh
# 'result' fires per intermediate snapshot (header keeps updating during load);
# 'result-final' fires once after the stream closes (footer shows the final count)
(seq 100; sleep 1; seq 100) | fzf --query 1 \
--bind 'result:transform-header(echo result: $FZF_MATCH_COUNT),result-final:transform-footer(echo final: $FZF_MATCH_COUNT)'
```
- Added `wait` action to block subsequent actions until search completes (#4825)
- Useful for chaining query-changing actions with motion actions to ensure operations on complete results
```sh
# Wait for search to complete before moving to the best match
fzf --bind 'start:change-query(foo)+wait+best'
```
- The initial loading of the input is also considered a search in progress, so `start:wait` can be used to wait until the input is fully loaded
```sh
# Move to the last item after the input is fully loaded
(seq 1000; sleep 1; seq 1001 2000) | fzf --bind 'start:wait+last'
```
- Bound `alt-left` to `backward-word` and `alt-right` to `forward-word` by default (#4833)
- Bug fixes and improvements
- Skip `$FZF_CURRENT_ITEM` export when the item is larger than 64 KB; a huge item can overflow `ARG_MAX` and break preview and other child commands with `E2BIG` (#4806)
- `transform` and `bg-transform` now allow a bare `put` action in the output to insert the key that triggered the action
```sh
# Insert the typed key ('a') into the query
fzf --bind 'a:transform:echo put'
```
- `ALT-C` in zsh no longer resolves symbolic links when changing the directory, consistent with the `cd` builtin (#4816) (@silverneko)
- Fixed horizontal mouse wheel events being treated as vertical scrolling (#4848) (@jason5122)
- Fixed `bw` theme not inheriting overridden colors
- fish: `CTRL-R` now works when `$fish_color_normal` or `$fish_color_comment` is empty or invalid (#4831) (@bitraid)
- Fixed empty-shell detection in the install script (#4813)
- Fixed the install script writing nushell source lines into the config files of other shells (#4812)
0.73.1
------
- Bug fixes
- Skip `$FZF_CURRENT_ITEM` export when the item contains a NUL byte; `exec(2)` rejects the env, breaking preview and other child commands (#4806)
- Fixed O(n^2) HTTP body accumulation in `--listen`; a single ~390 KB request could block the single-threaded server for ~8 s (Michal Majchrowicz, Marcin Wyczechowski, AFINE Team)
0.73.0
------
_Release highlights: https://junegunn.github.io/fzf/releases/0.73.0/_
- Nushell integration via `fzf --nushell` and the installer (#4630) (@sim590)
- New `--preview-window=next` position that places the preview adjacent to the input section, on the list side: above the input in the default layout, below it in `--layout=reverse` (#4798)
- Timer-driven `every(N)` event for `--bind`, where `N` is seconds
- Added `$FZF_IDLE_TIME` (whole seconds) and `$FZF_IDLE_TIME_MS` (milliseconds), holding the elapsed time since the last user activity
- Pair with `every(N)` to build idle-based behavior such as auto-accept or auto-quit (#1211)
```sh
# Live process list; --track --id-nth 2 keeps the cursor on the same PID across reloads
fzf --header-lines 1 --track --id-nth 2 --bind 'start,every(2):reload-sync:ps -ef'
# Auto-accept after 10 seconds of inactivity, with a countdown in the footer after 5s
fzf --bind 'every(1):bg-transform:
if [[ $FZF_IDLE_TIME -lt 5 ]]; then echo change-footer:
elif [[ $FZF_IDLE_TIME -lt 10 ]]; then echo "change-footer:auto-accept in $((10 - FZF_IDLE_TIME))s"
else echo accept
fi'
```
- Added `$FZF_CURRENT_ITEM` for shells where quoting `{}` is awkward (#4802)
- Bug fixes
- Scoring: non-word characters at the start of input or after a delimiter now receive the same boundary bonus as word characters (#4795)
- `change-preview-window` no longer resets `wrap` / `wrap-word` state set via `toggle-preview-wrap` / `toggle-preview-wrap-word` (#4791)
- Stripped UTF-8-encoded C1 control characters from rendered items to prevent terminal control-sequence injection
- Fixed integer-overflow panic in `FuzzyMatchV2` on 32-bit builds (Michal Majchrowicz, Marcin Wyczechowski, AFINE Team)
- Fixed `bg-transform` `reload` / `exclude` payloads being dropped
- Fixed rendering glitch with preview window on the left combined with footer
0.72.0
------
_Release highlights: https://junegunn.github.io/fzf/releases/0.72.0/_
- `--header-border`, `--header-lines-border`, and `--footer-border` now accept a new `inline` style that embeds the section inside the list frame, separated from the list content by a horizontal line. When the list border has side segments, the separator joins them as T-junctions.
- Requires a `--list-border` shape that has both top and bottom segments (`rounded`, `sharp`, `bold`, `double`, `block`, `thinblock`, or `horizontal`); falls back to `line` otherwise. `horizontal` has no side borders, so the separator is drawn without T-junction endpoints.
- Sections stack. Example combining all three:
```sh
ps -ef | fzf --reverse --style full \
--header 'Select a process' --header-lines 1 \
--bind 'load:transform-footer:echo $FZF_TOTAL_COUNT processes' \
--header-border dashed --header-first \
--header-lines-border inline --footer-border inline
```
- `--header-label` and `--footer-label` render on their respective separator row.
- The separator inherits `--color list-border` when the section's own border color is not explicitly set.
- `inline` takes precedence over `--header-first`: the inline section stays inside the list frame. `--header-border=inline` requires `--header-lines-border` to be `inline` or unset.
- New `dashed` border style with dashed edges (`` / ``) and rounded corners.
- `--border=dashed`, `--list-border=dashed`, etc.
- Works with inline sections (T-junctions render correctly).
- [vim] Move and resize popup window when detecting `VimResized` event (#4778) (@Vulcalien)
- Bug fixes
- Fixed gutter display in `--style=minimal`
- Fixed arrow keys / Home / End without modifiers being ignored under the kitty keyboard protocol (#4776) (@TymekDev)
- bash: Persist history deletion when `histappend` is on (#4764)
0.71.0
------
_Release highlights: https://junegunn.github.io/fzf/releases/0.71.0/_
- Added `--popup` as a new name for `--tmux` with Zellij support
- `--popup` starts fzf in a tmux popup or a Zellij floating pane
- `--tmux` is now an alias for `--popup`
- Requires tmux 3.3+ or Zellij 0.44+
- Cross-reload item identity with `--id-nth`
- Added `--id-nth=NTH` to define item identity fields for cross-reload operations
- When a `reload` is triggered with tracking enabled, fzf searches for the tracked item by its identity fields in the new list.
- `--track --id-nth ..` tracks by the entire line
- `--track --id-nth 1` tracks by the first field
- `--track` without `--id-nth` retains the existing index-based tracking behavior
- The UI is temporarily blocked (prompt dimmed, input disabled) until the item is found or loading completes.
- Press `Escape` or `Ctrl-C` to cancel the blocked state without quitting
- Info line shows `+T*` / `+t*` while searching
- With `--multi`, selected items are preserved across `reload-sync` by matching their identity fields
- Performance improvements
- The search performance now scales linearly with the number of CPU cores, as we dropped static partitioning to allow better load balancing across threads.
```
=== query: 'linux' ===
[all] baseline: 21.95ms current: 17.47ms (1.26x) matches: 179966 (12.79%)
[1T] baseline: 179.63ms current: 180.53ms (1.00x) matches: 179966 (12.79%)
[2T] baseline: 97.38ms current: 90.05ms (1.08x) matches: 179966 (12.79%)
[4T] baseline: 53.83ms current: 44.77ms (1.20x) matches: 179966 (12.79%)
[8T] baseline: 41.66ms current: 22.58ms (1.84x) matches: 179966 (12.79%)
```
- Improved the cache structure, reducing memory footprint per entry by 86x.
- With the reduced per-entry cost, the cache now has broader coverage.
- Shell integration improvements
- bash: CTRL-R now supports multi-select and `shift-delete` to delete history entries (#4715)
- fish:
- Improved command history (CTRL-R) (#4703) (@bitraid)
- Rewrite completion script (SHIFT-TAB) (#4731) (@bitraid)
- Increase minimum fish version requirement to 3.4.0 (#4731) (@bitraid)
- `GET /` HTTP endpoint now includes `positions` field in each match entry, providing the indices of matched characters for external highlighting (#4726)
- Allow adaptive height with negative value (`--height=~-HEIGHT`) (#4682)
- Bug fixes
- `--walker=follow` no longer follows symlinks whose target is an ancestor of the walker root, avoiding severe resource exhaustion when a symlink points outside the tree (e.g. Wine's `z:` → `/`) (#4710)
- Fixed AWK tokenizer not treating a new line character as whitespace
- Fixed `--{accept,with}-nth` removing trailing whitespaces with a non-default `--delimiter`
- Fixed OSC8 hyperlinks being mangled when the URL contains unicode characters (#4707)
- Fixed `--with-shell` not handling quoted arguments correctly (#4709)
- Fixed child processes not being terminated on Windows (#4723) (@pjeby)
- Fixed preview scrollbar not rendered after `toggle-preview`
- Fixed preview follow/scroll with long wrapped lines
- Fixed tab width when `--frozen-left` is used
- Fixed preview mouse events being processed when no preview window exists
- zsh: Fixed history widget when `sh_glob` option is on (#4714) (@EvanHahn)
0.70.0
------
- Added `change-with-nth` action for dynamically changing the `--with-nth` option.
- Requires `--with-nth` to be set initially.
- Multiple options separated by `|` can be given to cycle through.
```sh
echo -e "a b c\nd e f\ng h i" | fzf --with-nth .. \
--bind 'space:change-with-nth(1|2|3|1,3|2,3|)'
```
- Added `change-header-lines` action for dynamically changing the `--header-lines` option
- Performance improvements (1.3x to 1.9x faster filtering depending on query)
```
=== query: 'l' ===
[all] baseline: 168.87ms current: 95.21ms (1.77x) matches: 5069891 (94.78%)
[1T] baseline: 1652.22ms current: 841.40ms (1.96x) matches: 5069891 (94.78%)
=== query: 'lin' ===
[all] baseline: 343.27ms current: 252.59ms (1.36x) matches: 3516507 (65.74%)
[1T] baseline: 3199.89ms current: 2230.64ms (1.43x) matches: 3516507 (65.74%)
=== query: 'linux' ===
[all] baseline: 85.47ms current: 63.72ms (1.34x) matches: 307229 (5.74%)
[1T] baseline: 774.64ms current: 589.32ms (1.31x) matches: 307229 (5.74%)
=== query: 'linuxlinux' ===
[all] baseline: 55.13ms current: 35.67ms (1.55x) matches: 12230 (0.23%)
[1T] baseline: 461.99ms current: 332.38ms (1.39x) matches: 12230 (0.23%)
=== query: 'linuxlinuxlinux' ===
[all] baseline: 51.77ms current: 32.53ms (1.59x) matches: 865 (0.02%)
[1T] baseline: 409.99ms current: 296.33ms (1.38x) matches: 865 (0.02%)
```
- Fixed `nth` attribute merge order to respect precedence hierarchy (#4697)
- bash: Replaced `printf` with builtin `printf` to bypass local indirections (#4684) (@DarrenBishop)
0.68.0
------
- 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 0.67.0
------ ------
- Added `--freeze-left=N` option to keep the leftmost N columns always visible. - Added `--freeze-left=N` option to keep the leftmost N columns always visible.
+1 -11
View File
@@ -1,15 +1,5 @@
FROM rubylang/ruby:3.4.1-noble FROM rubylang/ruby:3.4.1-noble
RUN apt-get update && apt-get install -y git make golang zsh fish tmux RUN apt-get update -y && apt install -y git make golang zsh fish tmux
# https://www.nushell.sh/book/installation.html
RUN <<EOF
set -ex
apt-get install -y wget gnupg
wget -qO- https://apt.fury.io/nushell/gpg.key | gpg --dearmor -o /etc/apt/keyrings/fury-nushell.gpg
echo "deb [signed-by=/etc/apt/keyrings/fury-nushell.gpg] https://apt.fury.io/nushell/ /" | tee /etc/apt/sources.list.d/fury-nushell.list
apt-get update
apt-get install -y nushell
EOF
RUN gem install --no-document -v 5.22.3 minitest RUN gem install --no-document -v 5.22.3 minitest
RUN echo '. /usr/share/bash-completion/completions/git' >> ~/.bashrc RUN echo '. /usr/share/bash-completion/completions/git' >> ~/.bashrc
RUN echo '. ~/.bashrc' >> ~/.bash_profile RUN echo '. ~/.bashrc' >> ~/.bash_profile
+1 -1
View File
@@ -1,6 +1,6 @@
The MIT License (MIT) The MIT License (MIT)
Copyright (c) 2013-2026 Junegunn Choi Copyright (c) 2013-2025 Junegunn Choi
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+5 -21
View File
@@ -1,5 +1,4 @@
GO ?= go GO ?= go
DOCKER ?= docker
GOOS ?= $(shell $(GO) env GOOS) GOOS ?= $(shell $(GO) env GOOS)
MAKEFILE := $(realpath $(lastword $(MAKEFILE_LIST))) MAKEFILE := $(realpath $(lastword $(MAKEFILE_LIST)))
@@ -53,8 +52,6 @@ ifeq ($(UNAME_M),x86_64)
BINARY := $(BINARY64) BINARY := $(BINARY64)
else ifeq ($(UNAME_M),amd64) else ifeq ($(UNAME_M),amd64)
BINARY := $(BINARY64) BINARY := $(BINARY64)
else ifeq ($(UNAME_M),i86pc)
BINARY := $(BINARY64)
else ifeq ($(UNAME_M),s390x) else ifeq ($(UNAME_M),s390x)
BINARY := $(BINARYS390) BINARY := $(BINARYS390)
else ifeq ($(UNAME_M),i686) else ifeq ($(UNAME_M),i686)
@@ -117,19 +114,6 @@ generate:
build: build:
goreleaser build --clean --snapshot --skip=post-hooks goreleaser build --clean --snapshot --skip=post-hooks
prerelease:
# Check if version numbers are properly updated
grep -q ^$(VERSION_REGEX)$$ CHANGELOG.md
grep -qF '"fzf $(VERSION_TRIM)"' man/man1/fzf.1
grep -qF '"fzf $(VERSION_TRIM)"' man/man1/fzf-tmux.1
grep -qF $(VERSION) install
grep -qF $(VERSION) install.ps1
@echo "OK: all files consistent at $(VERSION)"
tag: prerelease
git tag -s v$(VERSION) -m v$(VERSION)
git push origin v$(VERSION)
release: release:
# Make sure that the tests pass and the build works # Make sure that the tests pass and the build works
TAGS=tcell make test TAGS=tcell make test
@@ -208,15 +192,15 @@ bin/fzf: target/$(BINARY) | bin
cp -f target/$(BINARY) bin/fzf cp -f target/$(BINARY) bin/fzf
docker: docker:
$(DOCKER) build -t fzf-ubuntu . docker build -t fzf-ubuntu .
$(DOCKER) run -it fzf-ubuntu tmux docker run -it fzf-ubuntu tmux
docker-test: docker-test:
$(DOCKER) build -t fzf-ubuntu . docker build -t fzf-ubuntu .
$(DOCKER) run -it fzf-ubuntu docker run -it fzf-ubuntu
update: update:
$(GO) get -u $(GO) get -u
$(GO) mod tidy $(GO) mod tidy
.PHONY: all generate build prerelease tag release test itest bench lint install clean docker docker-test update fmt .PHONY: all generate build release test itest bench lint install clean docker docker-test update fmt
+1 -1
View File
@@ -493,4 +493,4 @@ autocmd FileType fzf set laststatus=0 noshowmode noruler
The MIT License (MIT) The MIT License (MIT)
Copyright (c) 2013-2026 Junegunn Choi Copyright (c) 2013-2025 Junegunn Choi
+71 -215
View File
File diff suppressed because one or more lines are too long
-54
View File
@@ -1,54 +0,0 @@
Release process
===============
Building, signing, notarizing, and publishing is handled by
[`.github/workflows/release.yml`](.github/workflows/release.yml),
triggered by a tag push.
## Steps
1. Update version in the following files and commit on `master`:
- `CHANGELOG.md`
- `main.go`
- `install`
- `install.ps1`
- `man/man1/fzf.1`
- `man/man1/fzf-tmux.1`
2. Verify file consistency, sign the tag, and push the tag.
```sh
make tag VERSION=0.74.0
```
`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.
+8 -7
View File
@@ -159,11 +159,11 @@ fi
set -e set -e
# Clean up named pipes on exit # Clean up named pipes on exit
tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/fzf-tmux-XXXXXX") id=$RANDOM
argsf="$tmpdir/args" argsf="${TMPDIR:-/tmp}/fzf-args-$id"
fifo1="$tmpdir/fifo1" fifo1="${TMPDIR:-/tmp}/fzf-fifo1-$id"
fifo2="$tmpdir/fifo2" fifo2="${TMPDIR:-/tmp}/fzf-fifo2-$id"
fifo3="$tmpdir/fifo3" fifo3="${TMPDIR:-/tmp}/fzf-fifo3-$id"
if tmux_win_opts=$(tmux show-options -p remain-on-exit \; show-options -p synchronize-panes 2> /dev/null); then 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_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' tmux_off_opts='; set-option -p synchronize-panes off ; set-option -p remain-on-exit off'
@@ -172,7 +172,8 @@ else
tmux_off_opts='; set-window-option synchronize-panes off ; set-window-option remain-on-exit off' tmux_off_opts='; set-window-option synchronize-panes off ; set-window-option remain-on-exit off'
fi fi
cleanup() { cleanup() {
\rm -rf "$tmpdir" \rm -f $argsf $fifo1 $fifo2 $fifo3
# Restore tmux window options # Restore tmux window options
if [[ ${#tmux_win_opts[@]} -gt 1 ]]; then if [[ ${#tmux_win_opts[@]} -gt 1 ]]; then
eval "tmux ${tmux_win_opts[*]}" eval "tmux ${tmux_win_opts[*]}"
@@ -195,7 +196,7 @@ cleanup() {
trap 'cleanup 1' SIGUSR1 trap 'cleanup 1' SIGUSR1
trap 'cleanup' EXIT trap 'cleanup' EXIT
envs="export TERM=$(printf %q "$TERM") " envs="export TERM=$TERM "
if [[ $opt =~ "-E" ]]; then if [[ $opt =~ "-E" ]]; then
if [[ $tmux_version == 3.2 ]]; then if [[ $tmux_version == 3.2 ]]; then
FZF_DEFAULT_OPTS="--margin 0,1 $FZF_DEFAULT_OPTS" FZF_DEFAULT_OPTS="--margin 0,1 $FZF_DEFAULT_OPTS"
+5 -3
View File
@@ -112,7 +112,7 @@ the whole if we start off with `:FZF` command.
" Bang version starts fzf in fullscreen mode " Bang version starts fzf in fullscreen mode
:FZF! :FZF!
< <
Similarly to {ctrlp.vim}{3}, use Enter key, CTRL-T, CTRL-X or CTRL-V to open Similarly to {ctrlp.vim}{3}, use enter key, CTRL-T, CTRL-X or CTRL-V to open
selected files in the current window, in new tabs, in horizontal splits, or in selected files in the current window, in new tabs, in horizontal splits, or in
vertical splits respectively. vertical splits respectively.
@@ -218,6 +218,7 @@ list:
`fg` / `bg` / `hl` | Item (foreground / background / highlight) `fg` / `bg` / `hl` | Item (foreground / background / highlight)
`fg+` / `bg+` / `hl+` | Current item (foreground / background / highlight) `fg+` / `bg+` / `hl+` | Current item (foreground / background / highlight)
`preview-fg` / `preview-bg` | Preview window text and background `preview-fg` / `preview-bg` | Preview window text and background
`hl` / `hl+` | Highlighted substrings (normal / current)
`gutter` | Background of the gutter on the left `gutter` | Background of the gutter on the left
`pointer` | Pointer to the current line ( `>` ) `pointer` | Pointer to the current line ( `>` )
`marker` | Multi-select marker ( `>` ) `marker` | Multi-select marker ( `>` )
@@ -228,6 +229,7 @@ list:
`query` | Query string `query` | Query string
`disabled` | Query string when search is disabled `disabled` | Query string when search is disabled
`prompt` | Prompt before query ( `> ` ) `prompt` | Prompt before query ( `> ` )
`pointer` | Pointer to the current line ( `>` )
----------------------------+------------------------------------------------------ ----------------------------+------------------------------------------------------
- `component` specifies the component (`fg` / `bg`) from which to extract the - `component` specifies the component (`fg` / `bg`) from which to extract the
color when considering each of the following highlight groups color when considering each of the following highlight groups
@@ -243,7 +245,7 @@ if it exists, - otherwise use the `fg` attribute of the `Comment` highlight
group if it exists, - otherwise fall back to the default color settings for group if it exists, - otherwise fall back to the default color settings for
the prompt. the prompt.
You can examine the color option generated according to the setting by printing You can examine the color option generated according the setting by printing
the result of `fzf#wrap()` function like so: the result of `fzf#wrap()` function like so:
> >
:echo fzf#wrap() :echo fzf#wrap()
@@ -501,7 +503,7 @@ LICENSE *fzf-license*
The MIT License (MIT) The MIT License (MIT)
Copyright (c) 2013-2026 Junegunn Choi Copyright (c) 2013-2025 Junegunn Choi
============================================================================== ==============================================================================
vim:tw=78:sw=2:ts=2:ft=help:norl:nowrap: vim:tw=78:sw=2:ts=2:ft=help:norl:nowrap:
+1 -1
View File
@@ -4,7 +4,7 @@ require (
github.com/charlievieth/fastwalk v1.0.14 github.com/charlievieth/fastwalk v1.0.14
github.com/gdamore/tcell/v2 v2.9.0 github.com/gdamore/tcell/v2 v2.9.0
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741
github.com/mattn/go-isatty v0.0.22 github.com/mattn/go-isatty v0.0.20
github.com/rivo/uniseg v0.4.7 github.com/rivo/uniseg v0.4.7
golang.org/x/sys v0.35.0 golang.org/x/sys v0.35.0
golang.org/x/term v0.34.0 golang.org/x/term v0.34.0
+3 -2
View File
@@ -8,8 +8,8 @@ github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 h1:7dYDtfMD
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741/go.mod h1:6EILKtGpo5t+KLb85LNZLAF6P9LKp78hJI80PXMcn3c= github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741/go.mod h1:6EILKtGpo5t+KLb85LNZLAF6P9LKp78hJI80PXMcn3c=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -33,6 +33,7 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+25 -103
View File
@@ -2,11 +2,11 @@
set -u set -u
version=0.74.0 version=0.66.1
auto_completion= auto_completion=
key_bindings= key_bindings=
update_config=2 update_config=2
shells="bash zsh fish nushell" shells="bash zsh fish"
prefix='~/.fzf' prefix='~/.fzf'
prefix_expand=~/.fzf prefix_expand=~/.fzf
fish_dir=${XDG_CONFIG_HOME:-$HOME/.config}/fish fish_dir=${XDG_CONFIG_HOME:-$HOME/.config}/fish
@@ -27,7 +27,6 @@ usage: $0 [OPTIONS]
--no-bash Do not set up bash configuration --no-bash Do not set up bash configuration
--no-zsh Do not set up zsh configuration --no-zsh Do not set up zsh configuration
--no-fish Do not set up fish configuration --no-fish Do not set up fish configuration
--no-nushell Do not set up nushell configuration
EOF EOF
} }
@@ -57,7 +56,6 @@ for opt in "$@"; do
--no-bash) shells=${shells/bash/} ;; --no-bash) shells=${shells/bash/} ;;
--no-zsh) shells=${shells/zsh/} ;; --no-zsh) shells=${shells/zsh/} ;;
--no-fish) shells=${shells/fish/} ;; --no-fish) shells=${shells/fish/} ;;
--no-nushell) shells=${shells/nushell/} ;;
*) *)
echo "unknown option: $opt" echo "unknown option: $opt"
help help
@@ -114,15 +112,10 @@ link_fzf_in_path() {
return 1 return 1
} }
tar_opts="-xzf -"
if tar --no-same-owner -tf /dev/null 2> /dev/null; then
tar_opts="--no-same-owner $tar_opts"
fi
try_curl() { try_curl() {
command -v curl > /dev/null && command -v curl > /dev/null &&
if [[ $1 =~ tar.gz$ ]]; then if [[ $1 =~ tar.gz$ ]]; then
curl -fL $1 | tar $tar_opts curl -fL $1 | tar --no-same-owner -xzf -
else else
local temp=${TMPDIR:-/tmp}/fzf.zip local temp=${TMPDIR:-/tmp}/fzf.zip
curl -fLo "$temp" $1 && unzip -o "$temp" && rm -f "$temp" curl -fLo "$temp" $1 && unzip -o "$temp" && rm -f "$temp"
@@ -132,7 +125,7 @@ try_curl() {
try_wget() { try_wget() {
command -v wget > /dev/null && command -v wget > /dev/null &&
if [[ $1 =~ tar.gz$ ]]; then if [[ $1 =~ tar.gz$ ]]; then
wget -O - $1 | tar $tar_opts wget -O - $1 | tar --no-same-owner -xzf -
else else
local temp=${TMPDIR:-/tmp}/fzf.zip local temp=${TMPDIR:-/tmp}/fzf.zip
wget -O "$temp" $1 && unzip -o "$temp" && rm -f "$temp" wget -O "$temp" $1 && unzip -o "$temp" && rm -f "$temp"
@@ -184,7 +177,6 @@ case "$archi" in
Linux\ aarch64\ Android) download fzf-$version-android_arm64.tar.gz ;; Linux\ aarch64\ Android) download fzf-$version-android_arm64.tar.gz ;;
Linux\ aarch64*) download fzf-$version-linux_arm64.tar.gz ;; Linux\ aarch64*) download fzf-$version-linux_arm64.tar.gz ;;
Linux\ loongarch64*) download fzf-$version-linux_loong64.tar.gz ;; Linux\ loongarch64*) download fzf-$version-linux_loong64.tar.gz ;;
Linux\ riscv64*) download fzf-$version-linux_riscv64.tar.gz ;;
Linux\ ppc64le*) download fzf-$version-linux_ppc64le.tar.gz ;; Linux\ ppc64le*) download fzf-$version-linux_ppc64le.tar.gz ;;
Linux\ *64*) download fzf-$version-linux_amd64.tar.gz ;; Linux\ *64*) download fzf-$version-linux_amd64.tar.gz ;;
Linux\ s390x*) download fzf-$version-linux_s390x.tar.gz ;; Linux\ s390x*) download fzf-$version-linux_s390x.tar.gz ;;
@@ -226,14 +218,12 @@ fi
[[ $* =~ "--bin" ]] && exit 0 [[ $* =~ "--bin" ]] && exit 0
for s in $shells; do for s in $shells; do
bin=$s if ! command -v "$s" > /dev/null; then
[[ $s == nushell ]] && bin=nu
if ! command -v "$bin" > /dev/null; then
shells=${shells/$s/} shells=${shells/$s/}
fi fi
done done
if [[ -z ${shells// /} ]]; then if [[ ${#shells} -lt 3 ]]; then
echo "No shell configuration to be updated." echo "No shell configuration to be updated."
exit 0 exit 0
fi fi
@@ -252,17 +242,16 @@ fi
echo echo
for shell in $shells; do for shell in $shells; do
[[ $shell == nushell ]] && continue
fzf_completion="source \"$fzf_base/shell/completion.${shell}\""
fzf_key_bindings="source \"$fzf_base/shell/key-bindings.${shell}\""
[[ $shell == fish ]] && continue [[ $shell == fish ]] && continue
src=${prefix_expand}.${shell} src=${prefix_expand}.${shell}
echo -n "Generate $src ... " echo -n "Generate $src ... "
fzf_completion="source \"$fzf_base/shell/completion.${shell}\""
if [ $auto_completion -eq 0 ]; then if [ $auto_completion -eq 0 ]; then
fzf_completion="# $fzf_completion" fzf_completion="# $fzf_completion"
fi fi
fzf_key_bindings="source \"$fzf_base/shell/key-bindings.${shell}\""
if [ $key_bindings -eq 0 ]; then if [ $key_bindings -eq 0 ]; then
fzf_key_bindings="# $fzf_key_bindings" fzf_key_bindings="# $fzf_key_bindings"
fi fi
@@ -312,16 +301,15 @@ append_line() {
line="$2" line="$2"
file="$3" file="$3"
pat="${4:-}" pat="${4:-}"
at_lno="${5:-}"
lines="" lines=""
echo "Update $file:" echo "Update $file:"
echo " - $line" echo " - $line"
if [ -f "$file" ]; then if [ -f "$file" ]; then
if [[ -n $pat ]]; then if [ $# -lt 4 ]; then
lines=$(\grep -nF "$pat" "$file") lines=$(\grep -nF "$line" "$file")
else else
lines=$(\grep -nF "${line#"${line%%[![:space:]]*}"}" "$file") lines=$(\grep -nF "$pat" "$file")
fi fi
fi fi
@@ -339,12 +327,8 @@ append_line() {
set -e set -e
if [ "$update" -eq 1 ]; then if [ "$update" -eq 1 ]; then
if [[ -z $at_lno ]]; then [ -f "$file" ] && echo >> "$file"
[ -f "$file" ] && echo >> "$file" echo "$line" >> "$file"
echo "$line" >> "$file"
else
sed -i.~fzf_bak "${at_lno}a\\"$'\n'"$line" "$file" && rm "$file.~fzf_bak"
fi
echo " + Added" echo " + Added"
else else
echo " ~ Skipped" echo " ~ Skipped"
@@ -373,94 +357,33 @@ fi
echo echo
for shell in $shells; do for shell in $shells; do
[[ $shell == fish ]] && continue [[ $shell == fish ]] && continue
[[ $shell == nushell ]] && continue
[ $shell = zsh ] && dest=${ZDOTDIR:-~}/.zshrc || dest=~/.bashrc [ $shell = zsh ] && dest=${ZDOTDIR:-~}/.zshrc || dest=~/.bashrc
append_line $update_config "[ -f ${prefix}.${shell} ] && source ${prefix}.${shell}" "$dest" "${prefix}.${shell}" append_line $update_config "[ -f ${prefix}.${shell} ] && source ${prefix}.${shell}" "$dest" "${prefix}.${shell}"
done done
if [[ $shells =~ fish ]]; then if [ $key_bindings -eq 1 ] && [[ $shells =~ fish ]]; then
bind_file="${fish_dir}/functions/fish_user_key_bindings.fish" bind_file="${fish_dir}/functions/fish_user_key_bindings.fish"
if [ ! -e "$bind_file" ]; then if [ ! -e "$bind_file" ]; then
if [[ $key_bindings -eq 1 || $auto_completion -eq 1 ]]; then mkdir -p "${fish_dir}/functions"
mkdir -p "${fish_dir}/functions" create_file "$bind_file" \
if [[ $key_bindings -eq 1 && $auto_completion -eq 1 ]]; then 'function fish_user_key_bindings' \
create_file "$bind_file" \ ' fzf --fish | source' \
'function fish_user_key_bindings' \ 'end'
' 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 else
echo "Check $bind_file:" echo "Check $bind_file:"
lno_func=$(\grep -nF "function fish_user_key_bindings" "$bind_file" | sed 's/:.*//' | tr '\n' ' ') lno=$(\grep -nF "fzf_key_bindings" "$bind_file" | sed 's/:.*//' | tr '\n' ' ')
if [[ -z $lno_func ]]; then if [[ -n $lno ]]; then
echo -e "function fish_user_key_bindings\nend" >> "$bind_file" echo " ** Found 'fzf_key_bindings' in line #$lno"
lno_func=$(\grep -nF "function fish_user_key_bindings" "$bind_file" | sed 's/:.*//' | tr '\n' ' ') echo " ** You have to replace the line to 'fzf --fish | source'"
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 echo
else else
echo " - Clear" echo " - Clear"
echo echo
if [[ $key_bindings -eq 1 && $auto_completion -eq 1 ]]; then append_line $update_config "fzf --fish | source" "$bind_file"
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 fi
fi fi
if [[ $shells =~ nushell ]]; then
if [[ $key_bindings -eq 1 || $auto_completion -eq 1 ]]; then
echo "Setting up Nushell integration ..."
nushell_autoload_dir=$(nu -c '$nu.user-autoload-dirs | first')
mkdir -p "$nushell_autoload_dir"
dest="$nushell_autoload_dir/_fzf_integration.nu"
echo -n " Generate $dest ... "
if [[ $key_bindings -eq 1 && $auto_completion -eq 1 ]]; then
"$fzf_base"/bin/fzf --nushell > "$dest"
elif [[ $key_bindings -eq 1 ]]; then
cp "$fzf_base/shell/key-bindings.nu" "$dest"
else
cp "$fzf_base/shell/completion.nu" "$dest"
fi
echo "OK"
echo
fi
fi
if [ $update_config -eq 1 ]; then if [ $update_config -eq 1 ]; then
echo 'Finished. Restart your shell or reload config file.' echo 'Finished. Restart your shell or reload config file.'
if [[ $shells =~ bash ]]; then if [[ $shells =~ bash ]]; then
@@ -469,8 +392,7 @@ if [ $update_config -eq 1 ]; then
echo echo
fi fi
[[ $shells =~ zsh ]] && echo " source ${ZDOTDIR:-~}/.zshrc # zsh" [[ $shells =~ zsh ]] && echo " source ${ZDOTDIR:-~}/.zshrc # zsh"
[[ $shells =~ fish && $lno_func -ne 0 ]] && echo ' fish_user_key_bindings # fish' [[ $shells =~ fish ]] && [ $key_bindings -eq 1 ] && echo ' fzf_key_bindings # fish'
[[ $shells =~ nushell ]] && echo ' # nushell: files are loaded automatically from autoload directory'
echo echo
echo 'Use uninstall script to remove fzf.' echo 'Use uninstall script to remove fzf.'
echo echo
+1 -1
View File
@@ -1,4 +1,4 @@
$version="0.74.0" $version="0.66.1"
$fzf_base=Split-Path -Parent $MyInvocation.MyCommand.Definition $fzf_base=Split-Path -Parent $MyInvocation.MyCommand.Definition
+2 -16
View File
@@ -11,7 +11,7 @@ import (
"github.com/junegunn/fzf/src/protector" "github.com/junegunn/fzf/src/protector"
) )
var version = "0.74" var version = "0.66"
var revision = "devel" var revision = "devel"
//go:embed shell/key-bindings.bash //go:embed shell/key-bindings.bash
@@ -29,15 +29,6 @@ var zshCompletion []byte
//go:embed shell/key-bindings.fish //go:embed shell/key-bindings.fish
var fishKeyBindings []byte var fishKeyBindings []byte
//go:embed shell/key-bindings.nu
var nushellKeyBindings []byte
//go:embed shell/completion.nu
var nushellCompletion []byte
//go:embed shell/completion.fish
var fishCompletion []byte
//go:embed man/man1/fzf.1 //go:embed man/man1/fzf.1
var manPage []byte var manPage []byte
@@ -74,12 +65,7 @@ func main() {
} }
if options.Fish { if options.Fish {
printScript("key-bindings.fish", fishKeyBindings) printScript("key-bindings.fish", fishKeyBindings)
printScript("completion.fish", fishCompletion) fmt.Println("fzf_key_bindings")
return
}
if options.Nushell {
printScript("key-bindings.nu", nushellKeyBindings)
printScript("completion.nu", nushellCompletion)
return return
} }
if options.Help { if options.Help {
+2 -2
View File
@@ -1,7 +1,7 @@
.ig .ig
The MIT License (MIT) The MIT License (MIT)
Copyright (c) 2013-2026 Junegunn Choi Copyright (c) 2013-2025 Junegunn Choi
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal 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 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE. THE SOFTWARE.
.. ..
.TH fzf\-tmux 1 "Jul 2026" "fzf 0.74.0" "fzf\-tmux - open fzf in tmux split pane" .TH fzf\-tmux 1 "Oct 2025" "fzf 0.66.1" "fzf\-tmux - open fzf in tmux split pane"
.SH NAME .SH NAME
fzf\-tmux - open fzf in tmux split pane fzf\-tmux - open fzf in tmux split pane
+35 -261
View File
@@ -1,7 +1,7 @@
.ig .ig
The MIT License (MIT) The MIT License (MIT)
Copyright (c) 2013-2026 Junegunn Choi Copyright (c) 2013-2025 Junegunn Choi
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal 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 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE. THE SOFTWARE.
.. ..
.TH fzf 1 "Jul 2026" "fzf 0.74.0" "fzf - a command-line fuzzy finder" .TH fzf 1 "Oct 2025" "fzf 0.66.1" "fzf - a command-line fuzzy finder"
.SH NAME .SH NAME
fzf - a command-line fuzzy finder fzf - a command-line fuzzy finder
@@ -134,14 +134,6 @@ e.g.
# Use template to rearrange fields # Use template to rearrange fields
echo foo,bar,baz | fzf --delimiter , --with-nth '{n},{1},{3},{2},{1..2}' echo foo,bar,baz | fzf --delimiter , --with-nth '{n},{1},{3},{2},{1..2}'
.RE .RE
.RS
\fBchange\-with\-nth\fR action is only available when \fB\-\-with\-nth\fR is set.
When \fB\-\-with\-nth\fR is used, fzf retains the original input lines in memory
so they can be re\-transformed on the fly (e.g. \fB\-\-with\-nth ..\fR to keep
the original presentation). This increases memory usage, so only use
\fB\-\-with\-nth\fR when you actually need field transformation.
.RE
.TP .TP
.BI "\-\-accept\-nth=" "N[,..] or TEMPLATE" .BI "\-\-accept\-nth=" "N[,..] or TEMPLATE"
Define which fields to print on accept. The last delimiter is stripped from the Define which fields to print on accept. The last delimiter is stripped from the
@@ -226,7 +218,7 @@ Enable processing of ANSI color codes
Synchronous search for multi-staged filtering. If specified, fzf will launch Synchronous search for multi-staged filtering. If specified, fzf will launch
the finder only after the input stream is complete and the initial filtering the finder only after the input stream is complete and the initial filtering
and the associated actions (bound to any of \fBstart\fR, \fBload\fR, and the associated actions (bound to any of \fBstart\fR, \fBload\fR,
\fBresult\fR, \fBresult\-final\fR, or \fBfocus\fR) are complete. \fBresult\fR, or \fBfocus\fR) are complete.
.RS .RS
e.g. \fB# Avoid rendering both fzf instances at the same time e.g. \fB# Avoid rendering both fzf instances at the same time
@@ -280,7 +272,6 @@ color mappings. Each entry is separated by a comma and/or whitespaces.
\fBgutter \fRGutter on the left \fBgutter \fRGutter on the left
\fBcurrent\-hl (hl+) \fRHighlighted substrings (current line) \fBcurrent\-hl (hl+) \fRHighlighted substrings (current line)
\fBalt\-bg \fRAlternate background color to create striped lines \fBalt\-bg \fRAlternate background color to create striped lines
\fBalt\-gutter \fRAlternate gutter color to create the striped pattern
\fBquery (input\-fg) \fRQuery string \fBquery (input\-fg) \fRQuery string
\fBghost \fRGhost text (\fB\-\-ghost\fR, \fBdim\fR applied by default) \fBghost \fRGhost text (\fB\-\-ghost\fR, \fBdim\fR applied by default)
\fBdisabled \fRQuery string when search is disabled (\fB\-\-disabled\fR) \fBdisabled \fRQuery string when search is disabled (\fB\-\-disabled\fR)
@@ -334,14 +325,10 @@ color mappings. Each entry is separated by a comma and/or whitespaces.
\fB#rrggbb \fR24-bit colors \fB#rrggbb \fR24-bit colors
.B ANSI ATTRIBUTES: (Only applies to foreground colors) .B ANSI ATTRIBUTES: (Only applies to foreground colors)
\fBregular \fRClear previously set attributes; should precede the other ones \fBregular \fRClear previously set attributes; should precede the other ones
\fBstrip \fRRemove colors \fBstrip \fRRemove colors
\fBbold\fR \fBbold\fR
\fBunderline\fR \fBunderline\fR
\fBunderline-double\fR
\fBunderline-curly\fR
\fBunderline-dotted\fR
\fBunderline-dashed\fR
\fBreverse\fR \fBreverse\fR
\fBdim\fR \fBdim\fR
\fBitalic\fR \fBitalic\fR
@@ -384,7 +371,7 @@ Use black background
.SS DISPLAY MODE .SS DISPLAY MODE
.TP .TP
.BI "\-\-height=" "[~][\-]HEIGHT[%]" .BI "\-\-height=" "[~]HEIGHT[%]"
Display fzf window below the cursor with the given height instead of using Display fzf window below the cursor with the given height instead of using
the full screen. the full screen.
@@ -394,19 +381,17 @@ height minus the given value.
fzf \-\-height=\-1 fzf \-\-height=\-1
When prefixed with \fB~\fR, fzf will automatically determine the height in the When prefixed with \fB~\fR, fzf will automatically determine the height in the
range according to the input size. You can combine \fB~\fR with a negative range according to the input size.
value.
# Will not take up 100% of the screen # Will not take up 100% of the screen
seq 5 | fzf \-\-height=~100% seq 5 | fzf \-\-height=~100%
# Adapt to input size, up to terminal height minus 1
seq 5 | fzf \-\-height=~\-1
Adaptive height has the following limitations: Adaptive height has the following limitations:
.br .br
* Cannot be used with top/bottom margin and padding given in percent size * Cannot be used with top/bottom margin and padding given in percent size
.br .br
* Negative value is not allowed
.br
* It will not find the right size when there are multi-line items * It will not find the right size when there are multi-line items
.TP .TP
@@ -417,41 +402,25 @@ layout options so that the specified number of items are visible in the list
section (default: \fB10+\fR). section (default: \fB10+\fR).
Ignored when \fB\-\-height\fR is not specified or set as an absolute value. Ignored when \fB\-\-height\fR is not specified or set as an absolute value.
.TP .TP
.BI "\-\-popup" "[=[center|top|bottom|left|right][,SIZE[%]][,SIZE[%]][,border-native]]" .BI "\-\-tmux" "[=[center|top|bottom|left|right][,SIZE[%]][,SIZE[%]][,border-native]]"
Start fzf in a tmux or Zellij floating pane (default \fBcenter,50%\fR). Start fzf in a tmux popup (default \fBcenter,50%\fR). Requires tmux 3.3 or
Requires tmux 3.3+ or Zellij 0.44+. This option is ignored if you later. This option is ignored if you are not running fzf inside tmux.
are not running fzf inside tmux or Zellij. \fB\-\-tmux\fR is an alias for this option.
On tmux 3.7 or above and on Zellij, the floating pane is not modal; you can
switch to other panes and windows while fzf is running, and move and resize
the pane with the mouse. The native border of the pane is the handle for
moving and resizing it, so it is used by default and \fBborder\-native\fR is
implied. On tmux, \fB\-\-border\-label\fR is set as the title of the pane,
and is displayed on the border if \fBpane\-border\-status\fR is enabled in
tmux (\fB\-\-border\-label\-pos\fR is ignored).
fzf draws its own border instead when a border style is explicitly specified
with \fB\-\-border\fR, so that it is the only border shown. \fBnone\fR and
\fBline\fR are treated as no border. Give \fBborder\-native\fR to keep the
native border nonetheless. On tmux, the fzf\-drawn border is shown in a modal
popup, since the native border of a tmux floating pane cannot be removed;
this is also the case on tmux versions below 3.7.
e.g. e.g.
\fB# Popup in the center with 70% width and height \fB# Popup in the center with 70% width and height
fzf \-\-popup 70% fzf \-\-tmux 70%
# Popup on the left with 40% width and 100% height # Popup on the left with 40% width and 100% height
fzf \-\-popup right,40% fzf \-\-tmux right,40%
# Popup on the bottom with 100% width and 30% height # Popup on the bottom with 100% width and 30% height
fzf \-\-popup bottom,30% fzf \-\-tmux bottom,30%
# Popup on the top with 80% width and 40% height # Popup on the top with 80% width and 40% height
fzf \-\-popup top,80%,40% fzf \-\-tmux top,80%,40%
# Popup with a native tmux or Zellij border in the center with 80% width and height # Popup with a native tmux border in the center with 80% width and height
fzf \-\-popup center,80%,border\-native\fR fzf \-\-tmux center,80%,border\-native\fR
.SS LAYOUT .SS LAYOUT
.TP .TP
@@ -532,8 +501,6 @@ Draw border around the finder
.br .br
.BR double " Border with double lines" .BR double " Border with double lines"
.br .br
.BR dashed " Border with dashed lines and rounded corners"
.br
.BR block " Border using block elements; suitable when using different background colors" .BR block " Border using block elements; suitable when using different background colors"
.br .br
.BR thinblock " Border using legacy computing symbols; may not be displayed on some terminals" .BR thinblock " Border using legacy computing symbols; may not be displayed on some terminals"
@@ -621,11 +588,8 @@ Highlight the whole current line
.B "\-\-cycle" .B "\-\-cycle"
Enable cyclic scroll Enable cyclic scroll
.TP .TP
.BI "\-\-wrap" "[=MODE]" .B "\-\-wrap"
Enable line wrap. \fIMODE\fR can be \fBchar\fR (default) or \fBword\fR. Enable line wrap
\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 .TP
.BI "\-\-wrap\-sign" "=INDICATOR" .BI "\-\-wrap\-sign" "=INDICATOR"
Indicator for wrapped lines. The default is '↳ ' or '> ' depending on Indicator for wrapped lines. The default is '↳ ' or '> ' depending on
@@ -637,53 +601,17 @@ Disable multi-line display of items when using \fB\-\-read0\fR
.B "\-\-raw" .B "\-\-raw"
Enable raw mode where non-matching items are also displayed in a dimmed color. Enable raw mode where non-matching items are also displayed in a dimmed color.
.TP .TP
.BI "\-\-track" .B "\-\-track"
Make fzf track the current selection when the result list is updated. Make fzf track the current selection when the result list is updated.
This can be useful when browsing logs using fzf with sorting disabled. It is This can be useful when browsing logs using fzf with sorting disabled. It is
not recommended to use this option with \fB\-\-tac\fR as the resulting behavior not recommended to use this option with \fB\-\-tac\fR as the resulting behavior
can be confusing. can be confusing. Also, consider using \fBtrack\fR action instead of this
option.
When \fB\-\-id\-nth\fR is also set, fzf enables field\-based tracking across
\fBreload\fRs. See \fB\-\-id\-nth\fR for details.
Without \fB\-\-id\-nth\fR, \fB\-\-track\fR uses index\-based tracking that
does not persist across reloads.
.RS .RS
e.g. e.g.
\fB# Index\-based tracking (does not persist across reloads) \fBgit log \-\-oneline \-\-graph \-\-color=always | nl |
git log \-\-oneline \-\-graph \-\-color=always | nl |
fzf \-\-ansi \-\-track \-\-no\-sort \-\-layout=reverse\-list\fR fzf \-\-ansi \-\-track \-\-no\-sort \-\-layout=reverse\-list\fR
\fB# Track by first field (e.g. pod name) across reloads
kubectl get pods | fzf \-\-track \-\-id\-nth 1 \-\-header\-lines=1 \\
\-\-bind 'ctrl\-r:reload:kubectl get pods'\fR
.RE
.TP
.BI "\-\-id\-nth=" "N[,..]"
Define item identity fields for cross\-reload operations. When set, fzf
uses the specified fields to identify items across \fBreload\fR and
\fBreload\-sync\fR.
With \fB\-\-track\fR, fzf extracts the tracking key from the current item
using the nth expression and searches for a matching item in the reloaded list.
While searching, the UI is blocked (query input and cursor movement are
disabled, and the prompt is dimmed). With \fBreload\fR, the blocked state
clears as soon as the match is found in the stream. With \fBreload\-sync\fR,
the blocked state persists until the entire stream is complete. Press
\fBEscape\fR or \fBCtrl\-C\fR to cancel the blocked state without quitting fzf.
The info line shows \fB+T*\fR (or \fB+t*\fR for one\-off tracking) while
the search is in progress.
With \fB\-\-multi\fR, selected items are preserved across \fBreload\-sync\fR
by matching their identity fields in the reloaded list.
.RS
e.g.
\fB# Track and preserve selections by pod name across reloads
kubectl get pods | fzf \-\-multi \-\-track \-\-id\-nth 1 \-\-header\-lines=1 \\
\-\-bind 'ctrl\-r:reload\-sync:kubectl get pods'\fR
.RE .RE
.TP .TP
.B "\-\-tac" .B "\-\-tac"
@@ -812,7 +740,7 @@ ENVIRONMENT VARIABLES EXPORTED TO CHILD PROCESSES.
e.g. e.g.
\fB# Prepend the current cursor position in yellow \fB# Prepend the current cursor position in yellow
fzf \-\-info\-command='printf "\\x1b[33;1m$FZF_POS\\x1b[m/$FZF_INFO 💛"'\fR fzf \-\-info\-command='echo \-e "\\x1b[33;1m$FZF_POS\\x1b[m/$FZF_INFO 💛"'\fR
.TP .TP
.B "\-\-no\-info" .B "\-\-no\-info"
@@ -912,9 +840,6 @@ e.g.
# This won't work properly without 'f' flag due to ARG_MAX limit. # 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 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, Also,
* \fB{q}\fR is replaced to the current query string * \fB{q}\fR is replaced to the current query string
@@ -972,8 +897,6 @@ Should be used with one of the following \fB\-\-preview\-window\fR options.
.br .br
.B * border\-double .B * border\-double
.br .br
.B * border\-dashed
.br
.B * border\-block .B * border\-block
.br .br
.B * border\-thinblock .B * border\-thinblock
@@ -985,11 +908,6 @@ Should be used with one of the following \fB\-\-preview\-window\fR options.
.B * border\-bottom .B * border\-bottom
.br .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 .TP
.BI "\-\-preview\-label\-pos" [=N[:top|bottom]] .BI "\-\-preview\-label\-pos" [=N[:top|bottom]]
Position of the border label on the border line of the preview window. Specify Position of the border label on the border line of the preview window. Specify
@@ -1000,7 +918,7 @@ default value 0 (or \fBcenter\fR) will put the label at the center of the
border line. border line.
.TP .TP
.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)]" .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)]"
.RS .RS
.B POSITION: (default: right) .B POSITION: (default: right)
@@ -1008,14 +926,9 @@ border line.
\fBdown \fBdown
\fBleft \fBleft
\fBright \fBright
\fBnext
\fRDetermines the layout of the preview window. \fRDetermines the layout of the preview window.
* \fBnext\fR places the preview window adjacent to the input section, on
the list side: above the input in the default layout, below the input
in \fB\-\-layout=reverse\fR.
* If the argument contains \fB:hidden\fR, the preview window will be hidden by * If the argument contains \fB:hidden\fR, the preview window will be hidden by
default until \fBtoggle\-preview\fR action is triggered. default until \fBtoggle\-preview\fR action is triggered.
@@ -1023,8 +936,7 @@ default until \fBtoggle\-preview\fR action is triggered.
execute the command in the background. execute the command in the background.
* Long lines are truncated by default. Line wrap can be enabled with * Long lines are truncated by default. Line wrap can be enabled with
\fBwrap\fR flag. \fBwrap\-word\fR flag enables word-level wrapping, which \fBwrap\fR flag.
breaks lines at word boundaries instead of mid-word.
* Preview window will automatically scroll to the bottom when \fBfollow\fR * Preview window will automatically scroll to the bottom when \fBfollow\fR
flag is set, similarly to how \fBtail \-f\fR works. flag is set, similarly to how \fBtail \-f\fR works.
@@ -1124,17 +1036,7 @@ Print header before the prompt line. When both normal header and header lines
.TP .TP
.BI "\-\-header\-border" [=STYLE] .BI "\-\-header\-border" [=STYLE]
Draw border around the header section. \fBline\fR style draws a single Draw border around the header section. \fBline\fR style draws a single
separator line between the header window and the list section. \fBinline\fR separator line between the header window and the list section.
style embeds the header inside the list border frame, joined to the list
section by a horizontal separator; it requires a \fB\-\-list\-border\fR
shape that has both top and bottom segments (rounded / sharp / bold /
double / dashed / block / thinblock / horizontal) and falls back to \fBline\fR
otherwise. When the list border also has side segments, the separator
joins them with T-junctions; \fBhorizontal\fR has no side borders, so the
separator is drawn without T-junction endpoints. Takes precedence over
\fB\-\-header\-first\fR (the section stays inside the list frame), and
when \fB\-\-header\-lines\fR is also set \fB\-\-header\-lines\-border\fR
must also be \fBinline\fR.
.TP .TP
.BI "\-\-header\-label" [=LABEL] .BI "\-\-header\-label" [=LABEL]
@@ -1150,10 +1052,6 @@ Display header from \fB--header\-lines\fR with a separate border. Pass
\fBnone\fR to still separate the header lines but without a border. To combine \fBnone\fR to still separate the header lines but without a border. To combine
two headers, use \fB\-\-no\-header\-lines\-border\fR. \fBline\fR style draws two headers, use \fB\-\-no\-header\-lines\-border\fR. \fBline\fR style draws
a single separator line between the header lines and the list section. a single separator line between the header lines and the list section.
\fBinline\fR style embeds the header lines inside the list border frame
with a horizontal separator; it requires a \fB\-\-list\-border\fR shape
that has both top and bottom segments, falls back to \fBline\fR
otherwise.
.SS FOOTER .SS FOOTER
@@ -1167,10 +1065,7 @@ are not affected by \fB\-\-with\-nth\fR. ANSI color codes are processed even whe
.TP .TP
.BI "\-\-footer\-border" [=STYLE] .BI "\-\-footer\-border" [=STYLE]
Draw border around the footer section. \fBline\fR style draws a single Draw border around the footer section. \fBline\fR style draws a single
separator line between the footer and the list section. \fBinline\fR style separator line between the footer and the list section.
embeds the footer inside the list border frame with a horizontal separator;
it requires a \fB\-\-list\-border\fR shape that has both top and bottom
segments and falls back to \fBline\fR otherwise.
.TP .TP
.BI "\-\-footer\-label" [=LABEL] .BI "\-\-footer\-label" [=LABEL]
@@ -1317,18 +1212,6 @@ Here is an example script that uses a Unix socket instead of a TCP port.
curl --unix-socket /tmp/fzf.sock http -d up curl --unix-socket /tmp/fzf.sock http -d up
\fR \fR
.TP
.BI "\-\-threads=" "N"
Number of matcher threads to use. The default value is
\fBmin(8 * NUM_CPU, 32)\fR.
.TP
.BI "\-\-bench=" "DURATION"
Repeatedly run \fB\-\-filter\fR for the given duration and print timing
statistics. Must be used with \fB\-\-filter\fR.
e.g.
\fBcat /usr/share/dict/words | fzf \-\-filter abc \-\-bench 10s\fR
.SS DIRECTORY TRAVERSAL .SS DIRECTORY TRAVERSAL
.TP .TP
.B "\-\-walker=[file][,dir][,follow][,hidden]" .B "\-\-walker=[file][,dir][,follow][,hidden]"
@@ -1388,12 +1271,6 @@ Print script to set up Fish shell integration
e.g. \fBfzf \-\-fish | source\fR e.g. \fBfzf \-\-fish | source\fR
.TP
.B "\-\-nushell"
Print script to set up Nushell shell integration
e.g. \fBfzf \-\-nushell | save \-f ~/.config/nushell/autoload/_fzf_integration.nu\fR
.SS OTHERS .SS OTHERS
.TP .TP
.B "\-\-no\-mouse" .B "\-\-no\-mouse"
@@ -1496,18 +1373,12 @@ fzf exports the following environment variables to its child processes.
.br .br
.BR FZF_POS " Vertical position of the cursor in the list starting from 1" .BR FZF_POS " Vertical position of the cursor in the list starting from 1"
.br .br
.BR FZF_CURRENT_ITEM " Text of the current item (unset if the list is empty)"
.br
.BR FZF_WRAP " The line wrapping mode (char, word) when enabled"
.br
.BR FZF_QUERY " Current query string" .BR FZF_QUERY " Current query string"
.br .br
.BR FZF_INPUT_STATE " Current input state (enabled, disabled, hidden)" .BR FZF_INPUT_STATE " Current input state (enabled, disabled, hidden)"
.br .br
.BR FZF_NTH " Current \-\-nth option" .BR FZF_NTH " Current \-\-nth option"
.br .br
.BR FZF_WITH_NTH " Current \-\-with\-nth option"
.br
.BR FZF_PROMPT " Prompt string" .BR FZF_PROMPT " Prompt string"
.br .br
.BR FZF_GHOST " Ghost string" .BR FZF_GHOST " Ghost string"
@@ -1528,10 +1399,6 @@ fzf exports the following environment variables to its child processes.
.br .br
.BR FZF_KEY " The name of the last key pressed" .BR FZF_KEY " The name of the last key pressed"
.br .br
.BR FZF_IDLE_TIME " Whole seconds since the last user activity"
.br
.BR FZF_IDLE_TIME_MS " Milliseconds since the last user activity"
.br
.BR FZF_PORT " Port number when \-\-listen option is used" .BR FZF_PORT " Port number when \-\-listen option is used"
.br .br
.BR FZF_SOCK " Unix socket path when \-\-listen option is used" .BR FZF_SOCK " Unix socket path when \-\-listen option is used"
@@ -1546,13 +1413,6 @@ fzf exports the following environment variables to its child processes.
.br .br
.BR FZF_RAW " Only in raw mode. 1 if the current item matches, 0 otherwise" .BR FZF_RAW " Only in raw mode. 1 if the current item matches, 0 otherwise"
.PP
.B FZF_CURRENT_ITEM
is omitted when the item contains a NUL byte, because exec(2) cannot pass it.
It is also omitted when the item is larger than 64 KB, so that a huge item
cannot overflow the environment size limit and break preview and other child
commands.
.SH EXTENDED SEARCH MODE .SH EXTENDED SEARCH MODE
Unless specified otherwise, fzf will start in "extended\-search mode". In this Unless specified otherwise, fzf will start in "extended\-search mode". In this
@@ -1635,7 +1495,7 @@ e.g.
.br .br
\fIctrl\-/\fR (\fIctrl\-_\fR) \fIctrl\-/\fR (\fIctrl\-_\fR)
.br .br
\fIctrl\-alt\-[a\-z]\fR (\fIctrl\-alt\-h\fR is \fIctrl\-alt\-backspace\fR on non-Windows) \fIctrl\-alt\-[a\-z]\fR
.br .br
\fIalt\-[*]\fR (Any case-sensitive single character is allowed) \fIalt\-[*]\fR (Any case-sensitive single character is allowed)
.br .br
@@ -1765,7 +1625,7 @@ e.g.
.br .br
\fIctrl\-alt\-end\fR \fIctrl\-alt\-end\fR
.br .br
\fIctrl\-alt\-backspace\fR (\fIctrl\-alt\-bspace\fR \fIctrl\-alt\-bs\fR) (\fIctrl\-alt\-h\fR (non-Windows)) \fIctrl\-alt\-backspace\fR (\fIctrl\-alt\-bspace\fR \fIctrl\-alt\-bs\fR)
.br .br
\fIctrl\-alt\-delete\fR \fIctrl\-alt\-delete\fR
.br .br
@@ -1873,20 +1733,6 @@ e.g.
# * Note that you can't use 'change' event in this case because the second position may not be available # * Note that you can't use 'change' event in this case because the second position may not be available
fzf \-\-sync \-\-bind 'result:transform:[[ \-z {q} ]] && echo "pos(2)"'\fR fzf \-\-sync \-\-bind 'result:transform:[[ \-z {q} ]] && echo "pos(2)"'\fR
.RE .RE
\fIresult\-final\fR
.RS
Same as \fIresult\fR, but suppressed while the input stream is still open. Use
this when you want a one-shot action per query instead of one per intermediate
snapshot during loading.
e.g.
\fB# 'result' fires per intermediate snapshot (header keeps updating during load);
# 'result-final' fires once after the stream closes (footer shows the final count)
(seq 100; sleep 1; seq 100) | fzf \-\-query 1 \\
\-\-bind 'result:transform\-header(echo result: $FZF_MATCH_COUNT),result\-final:transform\-footer(echo final: $FZF_MATCH_COUNT)'\fR
.RE
\fIchange\fR \fIchange\fR
.RS .RS
Triggered whenever the query string is changed Triggered whenever the query string is changed
@@ -1992,30 +1838,6 @@ variables starting from 1. It optionally sets \fBFZF_CLICK_FOOTER_WORD\fR
if clicked on a word. if clicked on a word.
.RE .RE
\fIevery(N)\fR
.RS
Triggered every \fIN\fR seconds (\fIN\fR can be a fractional number, e.g.
\fB0.5\fR). The minimum interval is \fB0.01\fR seconds; values are floored
to that.
Combine with the \fBFZF_IDLE_TIME\fR (whole seconds) and
\fBFZF_IDLE_TIME_MS\fR (milliseconds) environment variables to build
idle\-based behavior without a separate event.
e.g.
\fB# Live process list, refreshed every 2 seconds.
# --track --id-nth 2 keeps the cursor on the same PID across reloads.
fzf \-\-header\-lines 1 \-\-track \-\-id\-nth 2 \\
\-\-bind 'start,every(2):reload\-sync:ps \-ef'
# Auto\-accept after 10 seconds of inactivity, with a countdown in the footer after 5s.
fzf \-\-bind 'every(1):bg\-transform:
if [[ $FZF_IDLE_TIME \-lt 5 ]]; then echo change\-footer:
elif [[ $FZF_IDLE_TIME \-lt 10 ]]; then echo "change\-footer:auto\-accept in $((10 \- FZF_IDLE_TIME))s"
else echo accept
fi'\fR
.RE
.SS AVAILABLE ACTIONS: .SS AVAILABLE ACTIONS:
A key or an event can be bound to one or more of the following actions. A key or an event can be bound to one or more of the following actions.
@@ -2030,7 +1852,7 @@ A key or an event can be bound to one or more of the following actions.
\fBbackward\-kill\-subword\fR \fBbackward\-kill\-subword\fR
\fBbackward\-kill\-word\fR \fIalt\-bs\fR \fBbackward\-kill\-word\fR \fIalt\-bs\fR
\fBbackward\-subword\fR \fBbackward\-subword\fR
\fBbackward\-word\fR \fIalt\-b shift\-left alt\-left\fR \fBbackward\-word\fR \fIalt\-b shift\-left\fR
\fBbecome(...)\fR (replace fzf process with the specified command; see below for the details) \fBbecome(...)\fR (replace fzf process with the specified command; see below for the details)
\fBbeginning\-of\-line\fR \fIctrl\-a home\fR \fBbeginning\-of\-line\fR \fIctrl\-a home\fR
\fBbell\fR (ring the terminal bell) \fBbell\fR (ring the terminal bell)
@@ -2040,14 +1862,12 @@ A key or an event can be bound to one or more of the following actions.
\fBchange\-border\-label(...)\fR (change \fB\-\-border\-label\fR to the given string) \fBchange\-border\-label(...)\fR (change \fB\-\-border\-label\fR to the given string)
\fBchange\-ghost(...)\fR (change ghost text to the given string) \fBchange\-ghost(...)\fR (change ghost text to the given string)
\fBchange\-header(...)\fR (change header to the given string; doesn't affect \fB\-\-header\-lines\fR) \fBchange\-header(...)\fR (change header to the given string; doesn't affect \fB\-\-header\-lines\fR)
\fBchange\-header\-lines(N)\fR (change the number of \fB\-\-header\-lines\fR)
\fBchange\-header\-label(...)\fR (change \fB\-\-header\-label\fR to the given string) \fBchange\-header\-label(...)\fR (change \fB\-\-header\-label\fR to the given string)
\fBchange\-input\-label(...)\fR (change \fB\-\-input\-label\fR to the given string) \fBchange\-input\-label(...)\fR (change \fB\-\-input\-label\fR to the given string)
\fBchange\-list\-label(...)\fR (change \fB\-\-list\-label\fR to the given string) \fBchange\-list\-label(...)\fR (change \fB\-\-list\-label\fR to the given string)
\fBchange\-multi\fR (enable multi-select mode with no limit) \fBchange\-multi\fR (enable multi-select mode with no limit)
\fBchange\-multi(...)\fR (enable multi-select mode with a limit or disable it with 0) \fBchange\-multi(...)\fR (enable multi-select mode with a limit or disable it with 0)
\fBchange\-nth(...)\fR (change \fB\-\-nth\fR option; rotate through the multiple options separated by '|') \fBchange\-nth(...)\fR (change \fB\-\-nth\fR option; rotate through the multiple options separated by '|')
\fBchange\-with\-nth(...)\fR (change \fB\-\-with\-nth\fR option; rotate through the multiple options separated by '|')
\fBchange\-pointer(...)\fR (change \fB\-\-pointer\fR option) \fBchange\-pointer(...)\fR (change \fB\-\-pointer\fR option)
\fBchange\-preview(...)\fR (change \fB\-\-preview\fR option) \fBchange\-preview(...)\fR (change \fB\-\-preview\fR option)
\fBchange\-preview\-label(...)\fR (change \fB\-\-preview\-label\fR to the given string) \fBchange\-preview\-label(...)\fR (change \fB\-\-preview\-label\fR to the given string)
@@ -2077,7 +1897,7 @@ A key or an event can be bound to one or more of the following actions.
\fBfirst\fR (move to the first match; same as \fBpos(1)\fR) \fBfirst\fR (move to the first match; same as \fBpos(1)\fR)
\fBforward\-char\fR \fIctrl\-f right\fR \fBforward\-char\fR \fIctrl\-f right\fR
\fBforward\-subword\fR \fBforward\-subword\fR
\fBforward\-word\fR \fIalt\-f shift\-right alt\-right\fR \fBforward\-word\fR \fIalt\-f shift\-right\fR
\fBignore\fR \fBignore\fR
\fBjump\fR (EasyMotion-like 2-keystroke movement) \fBjump\fR (EasyMotion-like 2-keystroke movement)
\fBkill\-line\fR \fBkill\-line\fR
@@ -2133,14 +1953,12 @@ A key or an event can be bound to one or more of the following actions.
\fBtoggle\-multi\-line\fR \fBtoggle\-multi\-line\fR
\fBtoggle\-preview\fR \fBtoggle\-preview\fR
\fBtoggle\-preview\-wrap\fR \fBtoggle\-preview\-wrap\fR
\fBtoggle\-preview\-wrap\-word\fR
\fBtoggle\-raw\fR (toggle raw mode for displaying non-matching items) \fBtoggle\-raw\fR (toggle raw mode for displaying non-matching items)
\fBtoggle\-search\fR (toggle search functionality) \fBtoggle\-search\fR (toggle search functionality)
\fBtoggle\-sort\fR \fBtoggle\-sort\fR
\fBtoggle\-track\fR (toggle global tracking option (\fB\-\-track\fR)) \fBtoggle\-track\fR (toggle global tracking option (\fB\-\-track\fR))
\fBtoggle\-track\-current\fR (toggle tracking of the current item) \fBtoggle\-track\-current\fR (toggle tracking of the current item)
\fBtoggle\-wrap\fR \fBtoggle\-wrap\fR \fIctrl\-/\fR \fIalt\-/\fR
\fBtoggle\-wrap\-word\fR \fIctrl\-/\fR \fIalt\-/\fR
\fBtoggle+down\fR \fIctrl\-i (tab)\fR \fBtoggle+down\fR \fIctrl\-i (tab)\fR
\fBtoggle+up\fR \fIbtab (shift\-tab)\fR \fBtoggle+up\fR \fIbtab (shift\-tab)\fR
\fBtrack\-current\fR (track the current item; automatically disabled if focus changes) \fBtrack\-current\fR (track the current item; automatically disabled if focus changes)
@@ -2148,12 +1966,10 @@ A key or an event can be bound to one or more of the following actions.
\fBtransform\-border\-label(...)\fR (transform border label using an external command) \fBtransform\-border\-label(...)\fR (transform border label using an external command)
\fBtransform\-ghost(...)\fR (transform ghost text using an external command) \fBtransform\-ghost(...)\fR (transform ghost text using an external command)
\fBtransform\-header(...)\fR (transform header using an external command) \fBtransform\-header(...)\fR (transform header using an external command)
\fBtransform\-header\-lines(...)\fR (transform the number of \fB\-\-header\-lines\fR using an external command)
\fBtransform\-header\-label(...)\fR (transform header label using an external command) \fBtransform\-header\-label(...)\fR (transform header label using an external command)
\fBtransform\-input\-label(...)\fR (transform input label using an external command) \fBtransform\-input\-label(...)\fR (transform input label using an external command)
\fBtransform\-list\-label(...)\fR (transform list label using an external command) \fBtransform\-list\-label(...)\fR (transform list label using an external command)
\fBtransform\-nth(...)\fR (transform nth using an external command) \fBtransform\-nth(...)\fR (transform nth using an external command)
\fBtransform\-with\-nth(...)\fR (transform with-nth using an external command)
\fBtransform\-pointer(...)\fR (transform pointer using an external command) \fBtransform\-pointer(...)\fR (transform pointer using an external command)
\fBtransform\-preview\-label(...)\fR (transform preview label using an external command) \fBtransform\-preview\-label(...)\fR (transform preview label using an external command)
\fBtransform\-prompt(...)\fR (transform prompt string using an external command) \fBtransform\-prompt(...)\fR (transform prompt string using an external command)
@@ -2164,7 +1980,6 @@ A key or an event can be bound to one or more of the following actions.
\fBunix\-line\-discard\fR \fIctrl\-u\fR \fBunix\-line\-discard\fR \fIctrl\-u\fR
\fBunix\-word\-rubout\fR \fIctrl\-w\fR \fBunix\-word\-rubout\fR \fIctrl\-w\fR
\fBuntrack\-current\fR (stop tracking the current item; no-op if global tracking is enabled) \fBuntrack\-current\fR (stop tracking the current item; no-op if global tracking is enabled)
\fBwait\fR (block action execution until search completes)
\fBup\fR \fIctrl\-k up\fR \fBup\fR \fIctrl\-k up\fR
\fBup\-match\fR \fIctrl\-p\fR \fIalt\-up\fR (move to the match above the cursor) \fBup\-match\fR \fIctrl\-p\fR \fIalt\-up\fR (move to the match above the cursor)
\fBup\-selected\fR (move to the selected item above the cursor) \fBup\-selected\fR (move to the selected item above the cursor)
@@ -2290,7 +2105,7 @@ payload of HTTP POST request to the \fB\-\-listen\fR server.
e.g. e.g.
\fB# Disallow selecting an empty line \fB# Disallow selecting an empty line
printf "1. Hello\\n2. Goodbye\\n\\n3. Exit" | echo \-e "1. Hello\\n2. Goodbye\\n\\n3. Exit" |
fzf \-\-height '~100%' \-\-reverse \-\-header 'Select one' \\ fzf \-\-height '~100%' \-\-reverse \-\-header 'Select one' \\
\-\-bind 'enter:transform:[[ \-n {} ]] && \-\-bind 'enter:transform:[[ \-n {} ]] &&
echo accept || echo accept ||
@@ -2317,47 +2132,6 @@ chain multiple transform actions where later ones depend on earlier results,
prefer using the \fBbg\fR variant. To cancel currently running background prefer using the \fBbg\fR variant. To cancel currently running background
transform processes, use \fBbg\-cancel\fR action. transform processes, use \fBbg\-cancel\fR action.
.SS WAITING FOR SEARCH COMPLETION
The \fBwait\fR action blocks the execution of subsequent actions until the
current search completes. This is useful when chaining query\-changing actions
with motion actions like \fBbest\fR or \fBfirst\fR, ensuring that the motion
action operates on the complete search results rather than stale data.
e.g.
\fBfzf \-\-bind 'start:change\-query(foo)+wait+best'\fR
In this example, \fBchange\-query(foo)\fR starts an asynchronous search for
the new query, \fBwait\fR blocks until the search completes, and \fBbest\fR
then moves the cursor to the best match in the complete result set.
The initial loading of the input is also considered a search in progress, so
\fBstart:wait\fR can be used to block until the input is fully loaded and
searched.
While waiting, user input is ignored, except for keys bound to \fBabort\fR or
\fBcancel\fR (\fIctrl\-c\fR, \fIctrl\-g\fR, \fIctrl\-q\fR, and \fIesc\fR by
default), which cancel the wait and discard the pending actions instead of
performing their usual role. The remaining actions of such a binding still run,
so a binding like \fBesc:cancel+first\fR is possible.
Asynchronous \fBbg\-transform\-*\fR actions are not affected; their results are
applied as soon as they arrive, even while waiting. For the same reason,
\fBwait\fR does not pair with them: in
\fBbg\-transform\-query(...)+wait\fR, the background command completes only
after \fBwait\fR has already been evaluated, so the search its result
eventually triggers is not waited for. Use the synchronous
\fBtransform\-query(...)\fR variant instead when chaining with \fBwait\fR.
If the search takes long enough, fzf indicates that it is waiting by dimming the
input, hiding the cursor, and showing \fB(..)\fR on the info line. This visual
feedback is debounced so that quick searches do not cause flickering.
Note that when searches are triggered in rapid succession (e.g. via
\fB\-\-listen\fR), \fBwait\fR may unblock on the completion of an earlier
search. Also, if the input source never completes, \fBwait\fR will block until
cancelled.
.SS PREVIEW BINDING .SS PREVIEW BINDING
With \fBpreview(...)\fR action, you can specify multiple different preview With \fBpreview(...)\fR action, you can specify multiple different preview
+26 -58
View File
@@ -1,4 +1,4 @@
" Copyright (c) 2013-2026 Junegunn Choi " Copyright (c) 2013-2025 Junegunn Choi
" "
" MIT License " MIT License
" "
@@ -896,7 +896,6 @@ function! s:execute_term(dict, command, temps) abort
endif endif
endfunction endfunction
function! fzf.on_exit(id, code, ...) function! fzf.on_exit(id, code, ...)
silent! autocmd! fzf_popup_resize
if s:getpos() == self.ppos " {'window': 'enew'} if s:getpos() == self.ppos " {'window': 'enew'}
for [opt, val] in items(self.winopts) for [opt, val] in items(self.winopts)
execute 'let' opt '=' val execute 'let' opt '=' val
@@ -1024,17 +1023,15 @@ function! s:callback(dict, lines) abort
endfunction endfunction
if has('nvim') if has('nvim')
function! s:create_popup() abort function s:create_popup(opts) abort
let opts = s:popup_bounds()
let opts = extend({'relative': 'editor', 'style': 'minimal'}, opts)
let buf = nvim_create_buf(v:false, v:true) let buf = nvim_create_buf(v:false, v:true)
let s:popup_id = nvim_open_win(buf, v:true, opts) let opts = extend({'relative': 'editor', 'style': 'minimal'}, a:opts)
call setwinvar(s:popup_id, '&colorcolumn', '') let win = nvim_open_win(buf, v:true, opts)
call setwinvar(win, '&colorcolumn', '')
" Colors " Colors
try try
call setwinvar(s:popup_id, '&winhighlight', 'Pmenu:,Normal:Normal') call setwinvar(win, '&winhighlight', 'Pmenu:,Normal:Normal')
let rules = get(g:, 'fzf_colors', {}) let rules = get(g:, 'fzf_colors', {})
if has_key(rules, 'bg') if has_key(rules, 'bg')
let color = call('s:get_color', rules.bg) let color = call('s:get_color', rules.bg)
@@ -1042,61 +1039,40 @@ if has('nvim')
let ns = nvim_create_namespace('fzf_popup') let ns = nvim_create_namespace('fzf_popup')
let hl = nvim_set_hl(ns, 'Normal', let hl = nvim_set_hl(ns, 'Normal',
\ &termguicolors ? { 'bg': color } : { 'ctermbg': str2nr(color) }) \ &termguicolors ? { 'bg': color } : { 'ctermbg': str2nr(color) })
call nvim_win_set_hl_ns(s:popup_id, ns) call nvim_win_set_hl_ns(win, ns)
endif endif
endif endif
catch catch
endtry endtry
return buf return buf
endfunction endfunction
function! s:resize_popup() abort
if !exists('s:popup_id') || !nvim_win_is_valid(s:popup_id)
return
endif
let opts = s:popup_bounds()
let opts = extend({'relative': 'editor'}, opts)
call nvim_win_set_config(s:popup_id, opts)
endfunction
else else
function! s:create_popup() abort function! s:create_popup(opts) abort
function! s:popup_create(buf) let s:popup_create = {buf -> popup_create(buf, #{
let s:popup_id = popup_create(a:buf, #{zindex: 1000}) \ line: a:opts.row,
call s:resize_popup() \ col: a:opts.col,
endfunction \ minwidth: a:opts.width,
\ maxwidth: a:opts.width,
\ minheight: a:opts.height,
\ maxheight: a:opts.height,
\ zindex: 1000,
\ })}
autocmd TerminalOpen * ++once call s:popup_create(str2nr(expand('<abuf>'))) autocmd TerminalOpen * ++once call s:popup_create(str2nr(expand('<abuf>')))
endfunction endfunction
function! s:resize_popup() abort
if !exists('s:popup_id') || empty(popup_getpos(s:popup_id))
return
endif
let opts = s:popup_bounds()
call popup_move(s:popup_id, {
\ 'line': opts.row,
\ 'col': opts.col,
\ 'minwidth': opts.width,
\ 'maxwidth': opts.width,
\ 'minheight': opts.height,
\ 'maxheight': opts.height,
\ })
endfunction
endif endif
function! s:popup_bounds() abort function! s:popup(opts) abort
let opts = s:popup_opts let xoffset = get(a:opts, 'xoffset', 0.5)
let yoffset = get(a:opts, 'yoffset', 0.5)
let xoffset = get(opts, 'xoffset', 0.5) let relative = get(a:opts, 'relative', 0)
let yoffset = get(opts, 'yoffset', 0.5)
let relative = get(opts, 'relative', 0)
" Use current window size for positioning relatively positioned popups " Use current window size for positioning relatively positioned popups
let columns = relative ? winwidth(0) : &columns let columns = relative ? winwidth(0) : &columns
let lines = relative ? winheight(0) : (&lines - has('nvim')) let lines = relative ? winheight(0) : (&lines - has('nvim'))
" Size and position " Size and position
let width = min([max([8, opts.width > 1 ? opts.width : float2nr(columns * opts.width)]), columns]) let width = min([max([8, a:opts.width > 1 ? a:opts.width : float2nr(columns * a:opts.width)]), columns])
let height = min([max([4, opts.height > 1 ? opts.height : float2nr(lines * opts.height)]), lines]) let height = min([max([4, a:opts.height > 1 ? a:opts.height : float2nr(lines * a:opts.height)]), lines])
let row = float2nr(yoffset * (lines - height)) + (relative ? win_screenpos(0)[0] - 1 : 0) let row = float2nr(yoffset * (lines - height)) + (relative ? win_screenpos(0)[0] - 1 : 0)
let col = float2nr(xoffset * (columns - width)) + (relative ? win_screenpos(0)[1] - 1 : 0) let col = float2nr(xoffset * (columns - width)) + (relative ? win_screenpos(0)[1] - 1 : 0)
@@ -1106,17 +1082,9 @@ function! s:popup_bounds() abort
let row += !has('nvim') let row += !has('nvim')
let col += !has('nvim') let col += !has('nvim')
return { 'row': row, 'col': col, 'width': width, 'height': height } call s:create_popup({
endfunction \ 'row': row, 'col': col, 'width': width, 'height': height
\ })
function! s:popup(opts) abort
let s:popup_opts = a:opts
call s:create_popup()
augroup fzf_popup_resize
autocmd!
autocmd VimResized * call s:resize_popup()
augroup END
endfunction endfunction
let s:default_action = { let s:default_action = {
+2 -2
View File
@@ -1,9 +1,9 @@
__fzf_defaults() { __fzf_defaults() {
# $1: Prepend to FZF_DEFAULT_OPTS_FILE and FZF_DEFAULT_OPTS # $1: Prepend to FZF_DEFAULT_OPTS_FILE and FZF_DEFAULT_OPTS
# $2: Append to FZF_DEFAULT_OPTS_FILE and FZF_DEFAULT_OPTS # $2: Append to FZF_DEFAULT_OPTS_FILE and FZF_DEFAULT_OPTS
builtin printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1" printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
command cat "${FZF_DEFAULT_OPTS_FILE-}" 2> /dev/null command cat "${FZF_DEFAULT_OPTS_FILE-}" 2> /dev/null
builtin printf '%s\n' "${FZF_DEFAULT_OPTS-} $2" printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
} }
__fzf_exec_awk() { __fzf_exec_awk() {
-91
View File
@@ -1,91 +0,0 @@
# ____ ____
# / __/___ / __/
# / /_/_ / / /_
# / __/ / /_/ __/
# /_/ /___/_/ 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}
# }
# }
+24 -71
View File
@@ -4,6 +4,8 @@
# / __/ / /_/ __/ # / __/ / /_/ __/
# /_/ /___/_/ completion.bash # /_/ /___/_/ completion.bash
# #
# - $FZF_TMUX (default: 0)
# - $FZF_TMUX_OPTS (default: empty)
# - $FZF_COMPLETION_TRIGGER (default: '**') # - $FZF_COMPLETION_TRIGGER (default: '**')
# - $FZF_COMPLETION_OPTS (default: empty) # - $FZF_COMPLETION_OPTS (default: empty)
# - $FZF_COMPLETION_PATH_OPTS (default: empty) # - $FZF_COMPLETION_PATH_OPTS (default: empty)
@@ -36,9 +38,9 @@ if [[ $- =~ i ]]; then
# the changes. See code comments in "common.sh" for the implementation details. # the changes. See code comments in "common.sh" for the implementation details.
__fzf_defaults() { __fzf_defaults() {
builtin printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1" printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
command cat "${FZF_DEFAULT_OPTS_FILE-}" 2> /dev/null command cat "${FZF_DEFAULT_OPTS_FILE-}" 2> /dev/null
builtin printf '%s\n' "${FZF_DEFAULT_OPTS-} $2" printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
} }
__fzf_exec_awk() { __fzf_exec_awk() {
@@ -79,7 +81,7 @@ __fzf_orig_completion() {
f="${BASH_REMATCH[2]}" f="${BASH_REMATCH[2]}"
cmd="${BASH_REMATCH[3]}" cmd="${BASH_REMATCH[3]}"
[[ $f == _fzf_* ]] && continue [[ $f == _fzf_* ]] && continue
builtin printf -v "_fzf_orig_completion_${cmd//[^A-Za-z0-9_]/_}" "%s" "${comp} %s ${cmd} #${f}" printf -v "_fzf_orig_completion_${cmd//[^A-Za-z0-9_]/_}" "%s" "${comp} %s ${cmd} #${f}"
if [[ $l == *" -o nospace "* ]] && [[ ${__fzf_nospace_commands-} != *" $cmd "* ]]; then if [[ $l == *" -o nospace "* ]] && [[ ${__fzf_nospace_commands-} != *" $cmd "* ]]; then
__fzf_nospace_commands="${__fzf_nospace_commands-} $cmd " __fzf_nospace_commands="${__fzf_nospace_commands-} $cmd "
fi fi
@@ -109,7 +111,7 @@ __fzf_orig_completion_instantiate() {
orig="${!orig_var-}" orig="${!orig_var-}"
orig="${orig%#*}" orig="${orig%#*}"
[[ $orig == *' %s '* ]] || return 1 [[ $orig == *' %s '* ]] || return 1
builtin printf -v REPLY "$orig" "$func" printf -v REPLY "$orig" "$func"
} }
_fzf_opts_completion() { _fzf_opts_completion() {
@@ -122,7 +124,6 @@ _fzf_opts_completion() {
+i --no-ignore-case +i --no-ignore-case
+s --no-sort +s --no-sort
+x --no-extended +x --no-extended
--accept-nth
--ansi --ansi
--bash --bash
--bind --bind
@@ -136,89 +137,56 @@ _fzf_opts_completion() {
--expect --expect
--filepath-word --filepath-word
--fish --fish
--footer
--footer-border
--footer-label
--footer-label-pos
--freeze-left
--freeze-right
--gap
--gap-line
--ghost
--gutter
--gutter-raw
--header --header
--header-border
--header-first --header-first
--header-label
--header-label-pos
--header-lines --header-lines
--header-lines-border
--height --height
--highlight-line --highlight-line
--history --history
--history-size --history-size
--hscroll-off --hscroll-off
--id-nth
--info --info
--info-command
--input-border
--input-label
--input-label-pos
--jump-labels --jump-labels
--keep-right --keep-right
--layout --layout
--listen --listen
--listen-unsafe --listen-unsafe
--list-border
--list-label
--list-label-pos
--literal --literal
--man --man
--margin --margin
--marker --marker
--marker-multi-line
--min-height --min-height
--no-bold --no-bold
--no-clear
--no-hscroll --no-hscroll
--no-input --no-mouse
--no-multi-line
--no-scrollbar --no-scrollbar
--no-separator --no-separator
--no-unicode
--padding --padding
--pointer --pointer
--preview --preview
--preview-border
--preview-label --preview-label
--preview-label-pos --preview-label-pos
--preview-window --preview-window
--print-query --print-query
--print0 --print0
--prompt --prompt
--raw
--read0 --read0
--reverse
--scheme --scheme
--scroll-off --scroll-off
--scrollbar
--separator --separator
--smart-case
--style
--sync --sync
--tabstop --tabstop
--tac --tac
--tail
--tiebreak --tiebreak
--tmux --tmux
--track --track
--version --version
--walker
--walker-root
--walker-skip
--with-nth --with-nth
--with-shell --with-shell
--wrap --wrap
--wrap-sign
--preview-wrap-sign
--zsh --zsh
-0 --exit-0 -0 --exit-0
-1 --select-1 -1 --select-1
@@ -238,11 +206,11 @@ _fzf_opts_completion() {
return 0 return 0
;; ;;
--tiebreak) --tiebreak)
COMPREPLY=($(compgen -W "length chunk pathname begin end index" -- "$cur")) COMPREPLY=($(compgen -W "length chunk begin end index" -- "$cur"))
return 0 return 0
;; ;;
--color) --color)
COMPREPLY=($(compgen -W "dark light base16 16 bw no" -- "$cur")) COMPREPLY=($(compgen -W "dark light 16 bw no" -- "$cur"))
return 0 return 0
;; ;;
--layout) --layout)
@@ -253,21 +221,12 @@ _fzf_opts_completion() {
COMPREPLY=($(compgen -W "default right hidden inline inline-right" -- "$cur")) COMPREPLY=($(compgen -W "default right hidden inline inline-right" -- "$cur"))
return 0 return 0
;; ;;
--wrap)
COMPREPLY=($(compgen -W "char word" -- "$cur"))
return 0
;;
--style)
COMPREPLY=($(compgen -W "default minimal full" -- "$cur"))
return 0
;;
--preview-window) --preview-window)
COMPREPLY=($(compgen -W " COMPREPLY=($(compgen -W "
default default
hidden hidden
nohidden nohidden
wrap wrap
wrap-word
nowrap nowrap
cycle cycle
nocycle nocycle
@@ -276,7 +235,6 @@ _fzf_opts_completion() {
left left
right right
rounded border border-rounded rounded border border-rounded
border-line
sharp border-sharp sharp border-sharp
border-bold border-bold
border-block border-block
@@ -290,16 +248,14 @@ _fzf_opts_completion() {
border-left border-left
border-right border-right
follow follow
nofollow nofollow" -- "$cur"))
info
noinfo" -- "$cur"))
return 0 return 0
;; ;;
--border | --list-border | --header-border | --header-lines-border | --footer-border | --input-border | --preview-border) --border)
COMPREPLY=($(compgen -W "line rounded sharp bold block thinblock double horizontal vertical top bottom left right none" -- "$cur")) COMPREPLY=($(compgen -W "rounded sharp bold block thinblock double horizontal vertical top bottom left right none" -- "$cur"))
return 0 return 0
;; ;;
--border-label-pos | --preview-label-pos | --list-label-pos | --header-label-pos | --footer-label-pos | --input-label-pos) --border-label-pos | --preview-label-pos)
COMPREPLY=($(compgen -W "center bottom top" -- "$cur")) COMPREPLY=($(compgen -W "center bottom top" -- "$cur"))
return 0 return 0
;; ;;
@@ -369,22 +325,19 @@ __fzf_generic_path_completion() {
matches=$( matches=$(
export FZF_DEFAULT_OPTS=$(__fzf_defaults "--reverse --scheme=path" "${FZF_COMPLETION_OPTS-} $2") export FZF_DEFAULT_OPTS=$(__fzf_defaults "--reverse --scheme=path" "${FZF_COMPLETION_OPTS-} $2")
unset FZF_DEFAULT_COMMAND FZF_DEFAULT_OPTS_FILE 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 if declare -F "$1" > /dev/null; then
eval "$1 $(builtin printf %q "$dir")" | __fzf_comprun "$4" -q "$leftover" "${rest[@]}" eval "$1 $(printf %q "$dir")" | __fzf_comprun "$4" -q "$leftover"
else else
if [[ $1 =~ dir ]]; then if [[ $1 =~ dir ]]; then
walker=dir,follow walker=dir,follow
eval "rest=(${FZF_COMPLETION_DIR_OPTS-})"
else else
walker=file,dir,follow,hidden walker=file,dir,follow,hidden
eval "rest=(${FZF_COMPLETION_PATH_OPTS-})"
fi fi
__fzf_comprun "$4" -q "$leftover" --walker "$walker" --walker-root="$dir" "${rest[@]}" __fzf_comprun "$4" -q "$leftover" --walker "$walker" --walker-root="$dir" "${rest[@]}"
fi | while read -r item; do fi | while read -r item; do
builtin printf "%q " "${item%$3}$3" printf "%q " "${item%$3}$3"
done done
) )
matches=${matches% } matches=${matches% }
@@ -394,9 +347,9 @@ __fzf_generic_path_completion() {
else else
COMPREPLY=("$cur") COMPREPLY=("$cur")
fi fi
# To redraw line after fzf closes (builtin printf '\e[5n') # To redraw line after fzf closes (printf '\e[5n')
bind '"\e[0n": redraw-current-line' 2> /dev/null bind '"\e[0n": redraw-current-line' 2> /dev/null
builtin printf '\e[5n' printf '\e[5n'
return 0 return 0
fi fi
dir=$(command dirname "$dir") dir=$(command dirname "$dir")
@@ -454,7 +407,7 @@ _fzf_complete() {
COMPREPLY=("$cur") COMPREPLY=("$cur")
fi fi
bind '"\e[0n": redraw-current-line' 2> /dev/null bind '"\e[0n": redraw-current-line' 2> /dev/null
builtin printf '\e[5n' printf '\e[5n'
return 0 return 0
else else
_fzf_handle_dynamic_completion "$cmd" "${rest[@]}" _fzf_handle_dynamic_completion "$cmd" "${rest[@]}"
@@ -526,7 +479,7 @@ _fzf_proc_completion_post() {
# # Set the local attribute for any non-local variable that is set by _known_hosts_real() # # Set the local attribute for any non-local variable that is set by _known_hosts_real()
# local COMPREPLY=() # local COMPREPLY=()
# _known_hosts_real '' # _known_hosts_real ''
# builtin printf '%s\n' "${COMPREPLY[@]}" | command sort -u --version-sort # printf '%s\n' "${COMPREPLY[@]}" | command sort -u --version-sort
# } # }
if ! declare -F __fzf_list_hosts > /dev/null; then if ! declare -F __fzf_list_hosts > /dev/null; then
__fzf_list_hosts() { __fzf_list_hosts() {
-169
View File
@@ -1,169 +0,0 @@
# ____ ____
# / __/___ / __/
# / /_/_ / / /_
# / __/ / /_/ __/
# /_/ /___/_/ 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
-489
View File
@@ -1,489 +0,0 @@
# ____ ____
# / __/___ / __/
# / /_/_ / / /_
# / __/ / /_/ __/
# /_/ /___/_/ 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 :
+7 -8
View File
@@ -4,6 +4,8 @@
# / __/ / /_/ __/ # / __/ / /_/ __/
# /_/ /___/_/ completion.zsh # /_/ /___/_/ completion.zsh
# #
# - $FZF_TMUX (default: 0)
# - $FZF_TMUX_OPTS (default: empty)
# - $FZF_COMPLETION_TRIGGER (default: '**') # - $FZF_COMPLETION_TRIGGER (default: '**')
# - $FZF_COMPLETION_OPTS (default: empty) # - $FZF_COMPLETION_OPTS (default: empty)
# - $FZF_COMPLETION_PATH_OPTS (default: empty) # - $FZF_COMPLETION_PATH_OPTS (default: empty)
@@ -100,9 +102,9 @@ if [[ -o interactive ]]; then
# the changes. See code comments in "common.sh" for the implementation details. # the changes. See code comments in "common.sh" for the implementation details.
__fzf_defaults() { __fzf_defaults() {
builtin printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1" printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
command cat "${FZF_DEFAULT_OPTS_FILE-}" 2> /dev/null command cat "${FZF_DEFAULT_OPTS_FILE-}" 2> /dev/null
builtin printf '%s\n' "${FZF_DEFAULT_OPTS-} $2" printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
} }
__fzf_exec_awk() { __fzf_exec_awk() {
@@ -172,18 +174,15 @@ __fzf_generic_path_completion() {
export FZF_DEFAULT_OPTS export FZF_DEFAULT_OPTS
FZF_DEFAULT_OPTS=$(__fzf_defaults "--reverse --scheme=path" "${FZF_COMPLETION_OPTS-}") FZF_DEFAULT_OPTS=$(__fzf_defaults "--reverse --scheme=path" "${FZF_COMPLETION_OPTS-}")
unset FZF_DEFAULT_COMMAND FZF_DEFAULT_OPTS_FILE 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 if declare -f "$compgen" > /dev/null; then
eval "$compgen $(printf %q "$dir")" | __fzf_comprun "$cmd_word" ${(Q)${(Z+n+)fzf_opts}} -q "$leftover" ${(Q)${(Z+n+)rest}} eval "$compgen $(printf %q "$dir")" | __fzf_comprun "$cmd_word" ${(Q)${(Z+n+)fzf_opts}} -q "$leftover"
else else
if [[ $compgen =~ dir ]]; then if [[ $compgen =~ dir ]]; then
walker=dir,follow walker=dir,follow
rest=${FZF_COMPLETION_DIR_OPTS-}
else else
walker=file,dir,follow,hidden walker=file,dir,follow,hidden
rest=${FZF_COMPLETION_PATH_OPTS-}
fi fi
__fzf_comprun "$cmd_word" ${(Q)${(Z+n+)fzf_opts}} -q "$leftover" --walker "$walker" --walker-root="$dir" ${(Q)${(Z+n+)rest}} < /dev/tty __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 fi | while read -r item; do
+13 -34
View File
@@ -4,6 +4,7 @@
# / __/ / /_/ __/ # / __/ / /_/ __/
# /_/ /___/_/ key-bindings.bash # /_/ /___/_/ key-bindings.bash
# #
# - $FZF_TMUX_OPTS
# - $FZF_CTRL_T_COMMAND # - $FZF_CTRL_T_COMMAND
# - $FZF_CTRL_T_OPTS # - $FZF_CTRL_T_OPTS
# - $FZF_CTRL_R_COMMAND # - $FZF_CTRL_R_COMMAND
@@ -24,9 +25,9 @@ if [[ $- =~ i ]]; then
# the changes. See code comments in "common.sh" for the implementation details. # the changes. See code comments in "common.sh" for the implementation details.
__fzf_defaults() { __fzf_defaults() {
builtin printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1" printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
command cat "${FZF_DEFAULT_OPTS_FILE-}" 2> /dev/null command cat "${FZF_DEFAULT_OPTS_FILE-}" 2> /dev/null
builtin printf '%s\n' "${FZF_DEFAULT_OPTS-} $2" printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
} }
__fzf_exec_awk() { __fzf_exec_awk() {
@@ -76,35 +77,17 @@ __fzf_cd__() {
) && printf 'builtin cd -- %q' "$(builtin unset CDPATH && builtin cd -- "$dir" && builtin pwd)" ) && printf 'builtin cd -- %q' "$(builtin unset CDPATH && builtin cd -- "$dir" && builtin pwd)"
} }
__fzf_history_delete() {
[[ -s $1 ]] || return
local offsets
offsets=($(sort -rnu "$1"))
for offset in "${offsets[@]}"; do
builtin history -d "$offset"
done
if [[ ${#offsets[@]} -gt 0 ]] && shopt -q histappend; then
builtin history -w
fi
}
if command -v perl > /dev/null; then if command -v perl > /dev/null; then
__fzf_history__() { __fzf_history__() {
local output script deletefile local output script
deletefile=$(mktemp)
script='BEGIN { getc; $/ = "\n\t"; $HISTCOUNT = $ENV{last_hist} + 1 } s/^[ *]//; s/\n/\n\t/gm; print $HISTCOUNT - $. . "\t$_" if !$seen{$_}++' script='BEGIN { getc; $/ = "\n\t"; $HISTCOUNT = $ENV{last_hist} + 1 } s/^[ *]//; s/\n/\n\t/gm; print $HISTCOUNT - $. . "\t$_" if !$seen{$_}++'
output=$( output=$(
set +o pipefail set +o pipefail
builtin fc -lnr -2147483648 | builtin fc -lnr -2147483648 |
last_hist=$(HISTTIMEFORMAT='' builtin history 1) command perl -n -l0 -e "$script" | last_hist=$(HISTTIMEFORMAT='' builtin history 1) command perl -n -l0 -e "$script" |
FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '"$'\t'"↳ ' --highlight-line --bind 'shift-delete:execute-silent(cat {+f1} >> \"$deletefile\")+exclude-multi' --multi ${FZF_CTRL_R_OPTS-} --read0") \ 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_FILE='' $(__fzfcmd) --query "$READLINE_LINE" FZF_DEFAULT_OPTS_FILE='' $(__fzfcmd) --query "$READLINE_LINE"
) ) || return
__fzf_history_delete "$deletefile"
command rm -f "$deletefile"
[[ -n $output ]] || return
READLINE_LINE=$(command perl -pe 's/^\d*\t//' <<< "$output") READLINE_LINE=$(command perl -pe 's/^\d*\t//' <<< "$output")
if [[ -z $READLINE_POINT ]]; then if [[ -z $READLINE_POINT ]]; then
echo "$READLINE_LINE" echo "$READLINE_LINE"
@@ -114,8 +97,7 @@ if command -v perl > /dev/null; then
} }
else # awk - fallback for POSIX systems else # awk - fallback for POSIX systems
__fzf_history__() { __fzf_history__() {
local output script deletefile local output script
deletefile=$(mktemp)
[[ $(HISTTIMEFORMAT='' builtin history 1) =~ [[:digit:]]+ ]] # how many history entries [[ $(HISTTIMEFORMAT='' builtin history 1) =~ [[:digit:]]+ ]] # how many history entries
script='function P(b) { ++n; sub(/^[ *]/, "", b); if (!seen[b]++) { printf "%d\t%s%c", '$((BASH_REMATCH + 1))' - n, b, 0 } } script='function P(b) { ++n; sub(/^[ *]/, "", b); if (!seen[b]++) { printf "%d\t%s%c", '$((BASH_REMATCH + 1))' - n, b, 0 } }
NR==1 { b = substr($0, 2); next } NR==1 { b = substr($0, 2); next }
@@ -126,12 +108,9 @@ else # awk - fallback for POSIX systems
set +o pipefail set +o pipefail
builtin fc -lnr -2147483648 2> /dev/null | # ( $'\t '<lines>$'\n' )* ; <lines> ::= [^\n]* ( $'\n'<lines> )* builtin fc -lnr -2147483648 2> /dev/null | # ( $'\t '<lines>$'\n' )* ; <lines> ::= [^\n]* ( $'\n'<lines> )*
__fzf_exec_awk "$script" | # ( <counter>$'\t'<lines>$'\000' )* __fzf_exec_awk "$script" | # ( <counter>$'\t'<lines>$'\000' )*
FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '"$'\t'"↳ ' --highlight-line --bind 'shift-delete:execute-silent(cat {+f1} >> \"$deletefile\")+exclude-multi' --multi ${FZF_CTRL_R_OPTS-} --read0") \ 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_FILE='' $(__fzfcmd) --query "$READLINE_LINE" FZF_DEFAULT_OPTS_FILE='' $(__fzfcmd) --query "$READLINE_LINE"
) ) || return
__fzf_history_delete "$deletefile"
command rm -f "$deletefile"
[[ -n $output ]] || return
READLINE_LINE=${output#*$'\t'} READLINE_LINE=${output#*$'\t'}
if [[ -z $READLINE_POINT ]]; then if [[ -z $READLINE_POINT ]]; then
echo "$READLINE_LINE" echo "$READLINE_LINE"
@@ -142,7 +121,7 @@ else # awk - fallback for POSIX systems
fi fi
# Required to refresh the prompt after fzf # Required to refresh the prompt after fzf
bind -m emacs-standard '"\C-\e(": redraw-current-line' bind -m emacs-standard '"\er": redraw-current-line'
bind -m vi-command '"\C-z": emacs-editing-mode' bind -m vi-command '"\C-z": emacs-editing-mode'
bind -m vi-insert '"\C-z": emacs-editing-mode' bind -m vi-insert '"\C-z": emacs-editing-mode'
@@ -151,7 +130,7 @@ bind -m emacs-standard '"\C-z": vi-editing-mode'
if ((BASH_VERSINFO[0] < 4)); then if ((BASH_VERSINFO[0] < 4)); then
# CTRL-T - Paste the selected file path into the command line # CTRL-T - Paste the selected file path into the command line
if [[ ${FZF_CTRL_T_COMMAND-x} != "" ]]; then if [[ ${FZF_CTRL_T_COMMAND-x} != "" ]]; then
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 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 vi-command '"\C-t": "\C-z\C-t\C-z"' bind -m vi-command '"\C-t": "\C-z\C-t\C-z"'
bind -m vi-insert '"\C-t": "\C-z\C-t\C-z"' bind -m vi-insert '"\C-t": "\C-z\C-t\C-z"'
fi fi
@@ -161,7 +140,7 @@ if ((BASH_VERSINFO[0] < 4)); then
if [[ -n ${FZF_CTRL_R_COMMAND-} ]]; 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 echo "warning: FZF_CTRL_R_COMMAND is set to a custom command, but custom commands are not yet supported for CTRL-R" >&2
fi fi
bind -m emacs-standard '"\C-r": "\C-e \C-u\C-y\ey\C-u`__fzf_history__`\e\C-e\C-\e("' bind -m emacs-standard '"\C-r": "\C-e \C-u\C-y\ey\C-u`__fzf_history__`\e\C-e\er"'
bind -m vi-command '"\C-r": "\C-z\C-r\C-z"' bind -m vi-command '"\C-r": "\C-z\C-r\C-z"'
bind -m vi-insert '"\C-r": "\C-z\C-r\C-z"' bind -m vi-insert '"\C-r": "\C-z\C-r\C-z"'
fi fi
@@ -186,7 +165,7 @@ fi
# ALT-C - cd into the selected directory # ALT-C - cd into the selected directory
if [[ ${FZF_ALT_C_COMMAND-x} != "" ]]; then if [[ ${FZF_ALT_C_COMMAND-x} != "" ]]; then
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 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 vi-command '"\ec": "\C-z\ec\C-z"' bind -m vi-command '"\ec": "\C-z\ec\C-z"'
bind -m vi-insert '"\ec": "\C-z\ec\C-z"' bind -m vi-insert '"\ec": "\C-z\ec\C-z"'
fi fi
+70 -53
View File
@@ -4,6 +4,7 @@
# / __/ / /_/ __/ # / __/ / /_/ __/
# /_/ /___/_/ key-bindings.fish # /_/ /___/_/ key-bindings.fish
# #
# - $FZF_TMUX_OPTS
# - $FZF_CTRL_T_COMMAND # - $FZF_CTRL_T_COMMAND
# - $FZF_CTRL_T_OPTS # - $FZF_CTRL_T_OPTS
# - $FZF_CTRL_R_COMMAND # - $FZF_CTRL_R_COMMAND
@@ -11,17 +12,20 @@
# - $FZF_ALT_C_COMMAND # - $FZF_ALT_C_COMMAND
# - $FZF_ALT_C_OPTS # - $FZF_ALT_C_OPTS
# Key bindings # Key bindings
# ------------ # ------------
# The oldest supported fish version is 3.1b1. To maintain compatibility, the
# command substitution syntax $(cmd) should never be used, even behind a version
# check, otherwise the source command will fail on fish versions older than 3.4.0.
function fzf_key_bindings function fzf_key_bindings
# The oldest supported fish version is 3.4.0. For this message being able to be # Check fish version
# displayed on older versions, the command substitution syntax $() should not set -l fish_ver (string match -r '^(\d+).(\d+)' $version 2> /dev/null; or echo 0\n0\n0)
# be used anywhere in the script, otherwise the source command will fail. if test \( "$fish_ver[2]" -lt 3 \) -o \( "$fish_ver[2]" -eq 3 -a "$fish_ver[3]" -lt 1 \)
if string match -qr -- '^[12]\\.|^3\\.[0-3]' $version echo "This script requires fish version 3.1b1 or newer." >&2
echo "fzf key bindings script requires fish version 3.4.0 or newer." >&2
return 1 return 1
else if not command -q fzf else if not type -q fzf
echo "fzf was not found in path." >&2 echo "fzf was not found in path." >&2
return 1 return 1
end end
@@ -33,7 +37,7 @@ function fzf_key_bindings
string join ' ' -- \ string join ' ' -- \
"--height $FZF_TMUX_HEIGHT --min-height=20+ --bind=ctrl-z:ignore" $argv[1] \ "--height $FZF_TMUX_HEIGHT --min-height=20+ --bind=ctrl-z:ignore" $argv[1] \
(test -r "$FZF_DEFAULT_OPTS_FILE"; and string join -- ' ' <$FZF_DEFAULT_OPTS_FILE) \ (test -r "$FZF_DEFAULT_OPTS_FILE"; and string join -- ' ' <$FZF_DEFAULT_OPTS_FILE) \
$FZF_DEFAULT_OPTS $argv[2..] $FZF_DEFAULT_OPTS $argv[2..-1]
end end
function __fzfcmd function __fzfcmd
@@ -52,25 +56,38 @@ function fzf_key_bindings
set -l prefix '' set -l prefix ''
set -l dir '.' set -l dir '.'
set -l -- match_regex '(?<fzf_query>[\\s\\S]*?(?=\\n?$)$)' # Set variables containing the major and minor fish version numbers, using
set -l -- prefix_regex '^-[^\\s=]+=|^-(?!-)\\S' # 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]
# Don't use option prefix if " -- " is preceded. # fish v3.3.0 and newer: Don't use option prefix if " -- " is preceded.
string match -qv -- '* -- *' (string sub -l (commandline -Cp) -- (commandline -p)) set -l -- match_regex '(?<fzf_query>[\s\S]*?(?=\n?$)$)'
and set -- match_regex "(?<prefix>$prefix_regex)?$match_regex" set -l -- prefix_regex '^-[^\s=]+=|^-(?!-)\S'
if test "$fish_major" -eq 3 -a "$fish_minor" -lt 3
or string match -q -v -- '* -- *' (string sub -l (commandline -Cp) -- (commandline -p))
set -- match_regex "(?<prefix>$prefix_regex)?$match_regex"
end
# Set $prefix and expanded $fzf_query with preserved trailing newlines. # Set $prefix and expanded $fzf_query with preserved trailing newlines.
if string match -qr -- '^\\d\\d+|^[4-9]' $version if test "$fish_major" -ge 4
# fish v4.0.0 and newer # fish v4.0.0 and newer
string match -q -r -- $match_regex (commandline --current-token --tokens-expanded | string collect -N) string match -q -r -- $match_regex (commandline --current-token --tokens-expanded | string collect -N)
else 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) 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)' '') eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r -a '^\\\(?=~)|\\\(?=\$\w)' '')
else
# fish older than v3.2.0 (v3.1b1 - v3.1.2)
set -l -- cl_token (commandline --current-token --tokenize | string collect -N)
set -- prefix (string match -r -- $prefix_regex $cl_token)
set -- fzf_query (string replace -- "$prefix" '' $cl_token | string collect -N)
eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r -a '^\\\(?=~)|\\\(?=\$\w)|\\\n\\\n$' '')
end end
if test -n "$fzf_query" if test -n "$fzf_query"
# Normalize path in $fzf_query, set $dir to the longest existing directory. # Normalize path in $fzf_query, set $dir to the longest existing directory.
if string match -qr -- '^\\d\\d+|^4|^3\\.[5-9]' $version if test \( "$fish_major" -ge 4 \) -o \( "$fish_major" -eq 3 -a "$fish_minor" -ge 5 \)
# fish v3.5.0 and newer # fish v3.5.0 and newer
set -- fzf_query (path normalize -- $fzf_query) set -- fzf_query (path normalize -- $fzf_query)
set -- dir $fzf_query set -- dir $fzf_query
@@ -78,22 +95,35 @@ function fzf_key_bindings
set -- dir (path dirname $dir) set -- dir (path dirname $dir)
end end
else else
string match -q -r -- '(?<fzf_query>^[\\s\\S]*?(?=\\n?$)$)' \ # fish older than v3.5.0 (v3.1b1 - v3.4.1)
(string replace -r -a -- '(?<=/)/|(?<!^)/+(?!\\n)$' '' $fzf_query | string collect -N) if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2
# fish v3.2.0 - v3.4.1
string match -q -r -- '(?<fzf_query>^[\s\S]*?(?=\n?$)$)' \
(string replace -r -a -- '(?<=/)/|(?<!^)/+(?!\n)$' '' $fzf_query | string collect -N)
else
# fish v3.1b1 - v3.1.2
set -- fzf_query (string replace -r -a -- '(?<=/)/|(?<!^)/+(?!\n)$' '' $fzf_query | string collect -N)
eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r '\\\n$' '')
end
set -- dir $fzf_query set -- dir $fzf_query
while not test -d "$dir" while not test -d "$dir"
set -- dir (dirname -z -- "$dir" | string split0) set -- dir (dirname -z -- "$dir" | string split0)
end end
end end
if not string match -q -- '.' $dir; or string match -qr -- '^\\.(/|$)' $fzf_query if not string match -q -- '.' $dir; or string match -q -r -- '^\./|^\.$' $fzf_query
# Strip $dir from $fzf_query - preserve trailing newlines. # Strip $dir from $fzf_query - preserve trailing newlines.
if string match -qr -- '^\\d\\d+|^[4-9]' $version if test "$fish_major" -ge 4
# fish v4.0.0 and newer # fish v4.0.0 and newer
string match -q -r -- '^'(string escape --style=regex -- $dir)'/?(?<fzf_query>[\\s\\S]*)' $fzf_query string match -q -r -- '^'(string escape --style=regex -- $dir)'/?(?<fzf_query>[\s\S]*)' $fzf_query
else else if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2
string match -q -r -- '^/?(?<fzf_query>[\\s\\S]*?(?=\\n?$)$)' \ # 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 replace -- "$dir" '' $fzf_query | string collect -N)
else
# fish older than v3.2.0 (v3.1b1 - v3.1.2)
set -- fzf_query (string replace -- "$dir" '' $fzf_query | string collect -N)
eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r -a '^/?|\\\n$' '')
end end
end end
end end
@@ -110,13 +140,13 @@ function fzf_key_bindings
set -lx FZF_DEFAULT_OPTS (__fzf_defaults \ set -lx FZF_DEFAULT_OPTS (__fzf_defaults \
"--reverse --walker=file,dir,follow,hidden --scheme=path" \ "--reverse --walker=file,dir,follow,hidden --scheme=path" \
"--multi $FZF_CTRL_T_OPTS --print0") "$FZF_CTRL_T_OPTS --multi --print0")
set -lx FZF_DEFAULT_COMMAND "$FZF_CTRL_T_COMMAND" set -lx FZF_DEFAULT_COMMAND "$FZF_CTRL_T_COMMAND"
set -lx FZF_DEFAULT_OPTS_FILE set -lx FZF_DEFAULT_OPTS_FILE
set -l result (eval (__fzfcmd) --walker-root=$dir --query=$fzf_query | string split0) set -l result (eval (__fzfcmd) --walker-root=$dir --query=$fzf_query | string split0)
and commandline -rt -- (string join -- ' ' $prefix(string escape -n -- $result))' ' and commandline -rt -- (string join -- ' ' $prefix(string escape -- $result))' '
commandline -f repaint commandline -f repaint
end end
@@ -127,34 +157,24 @@ function fzf_key_bindings
set -l -- total_lines (count $command_line) set -l -- total_lines (count $command_line)
set -l -- fzf_query (string escape -- $command_line[$current_line]) set -l -- fzf_query (string escape -- $command_line[$current_line])
set -lx -- FZF_DEFAULT_OPTS (__fzf_defaults '' \ set -lx FZF_DEFAULT_OPTS (__fzf_defaults '' \
'--with-nth=2.. --nth=2..,.. --scheme=history --multi --no-multi-line' \ '--nth=2..,.. --scheme=history --multi --wrap-sign="\t↳ "' \
'--no-wrap --wrap-sign="\t\t\t↳ " --preview-wrap-sign="↳ " --freeze-left=1' \ '--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)\'' \
'--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" \ "--bind=ctrl-r:toggle-sort,alt-r:toggle-raw --highlight-line $FZF_CTRL_R_OPTS" \
'--accept-nth=3.. --delimiter="\t" --tabstop=4 --read0 --print0 --with-shell='(status fish-path)\\ -c) '--accept-nth=2.. --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_OPTS_FILE
set -lx FZF_DEFAULT_COMMAND
set -lx -- FZF_DEFAULT_COMMAND 'builtin history -z' if type -q perl
set -a FZF_DEFAULT_OPTS '--tac'
# Enable syntax highlighting colors on fish v4.3.3 and newer set FZF_DEFAULT_COMMAND 'builtin history -z --reverse | command perl -0 -pe \'s/^/$.\t/g; s/\n/\n\t/gm\''
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 2>/dev/null; or set_color normal)"%F %a %T%t%s%t"(set_color normal)'
else else
set -a -- FZF_DEFAULT_COMMAND '--show-time="%F %a %T%t%s%t"' 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'
end end
# Merge history from other sessions before searching # Merge history from other sessions before searching
@@ -162,11 +182,11 @@ function fzf_key_bindings
if set -l result (eval $FZF_DEFAULT_COMMAND \| (__fzfcmd) --query=$fzf_query | string split0) if set -l result (eval $FZF_DEFAULT_COMMAND \| (__fzfcmd) --query=$fzf_query | string split0)
if test "$total_lines" -eq 1 if test "$total_lines" -eq 1
commandline -- $result commandline -- (string replace -a -- \n\t \n $result)
else else
set -l a (math $current_line - 1) set -l a (math $current_line - 1)
set -l b (math $current_line + 1) set -l b (math $current_line + 1)
commandline -- $command_line[1..$a] $result commandline -- $command_line[1..$a] (string replace -a -- \n\t \n $result)
commandline -a -- '' $command_line[$b..-1] commandline -a -- '' $command_line[$b..-1]
end end
end end
@@ -214,6 +234,3 @@ function fzf_key_bindings
end end
end end
# Run setup
fzf_key_bindings
-166
View File
@@ -1,166 +0,0 @@
# ____ ____
# / __/___ / __/
# / /_/_ / / /_
# / __/ / /_/ __/
# /_/ /___/_/ 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
}
+10 -42
View File
@@ -4,6 +4,7 @@
# / __/ / /_/ __/ # / __/ / /_/ __/
# /_/ /___/_/ key-bindings.zsh # /_/ /___/_/ key-bindings.zsh
# #
# - $FZF_TMUX_OPTS
# - $FZF_CTRL_T_COMMAND # - $FZF_CTRL_T_COMMAND
# - $FZF_CTRL_T_OPTS # - $FZF_CTRL_T_OPTS
# - $FZF_CTRL_R_COMMAND # - $FZF_CTRL_R_COMMAND
@@ -44,9 +45,9 @@ if [[ -o interactive ]]; then
# the changes. See code comments in "common.sh" for the implementation details. # the changes. See code comments in "common.sh" for the implementation details.
__fzf_defaults() { __fzf_defaults() {
builtin printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1" printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
command cat "${FZF_DEFAULT_OPTS_FILE-}" 2> /dev/null command cat "${FZF_DEFAULT_OPTS_FILE-}" 2> /dev/null
builtin printf '%s\n' "${FZF_DEFAULT_OPTS-} $2" printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
} }
__fzf_exec_awk() { __fzf_exec_awk() {
@@ -110,14 +111,8 @@ fzf-cd-widget() {
zle redisplay zle redisplay
return 0 return 0
fi fi
# Use subshell expansion to get the absolute PWD of the target dir.
# This allows the recorded shell history to be reused even from a different
# working directory.
# If failed, fallback to the unexpanded path to surface the error to the user.
# NOTE: Don't use the `:a` modifier as it resolves symlinks like `pwd -P`.
dir=$(builtin cd >/dev/null -- "${dir}" && echo "${PWD}" || echo "${dir}")
zle push-line # Clear buffer. Auto-restored on next prompt. zle push-line # Clear buffer. Auto-restored on next prompt.
BUFFER="builtin cd -- ${(q)dir}" BUFFER="builtin cd -- ${(q)dir:a}"
zle accept-line zle accept-line
local ret=$? local ret=$?
unset dir # ensure this doesn't end up appearing in prompt expansion unset dir # ensure this doesn't end up appearing in prompt expansion
@@ -133,52 +128,25 @@ fi
# CTRL-R - Paste the selected command from history into the command line # CTRL-R - Paste the selected command from history into the command line
fzf-history-widget() { fzf-history-widget() {
local selected extracted_with_perl=0 local selected
setopt localoptions noglobsubst noposixbuiltins pipefail no_aliases no_glob no_sh_glob no_ksharrays extendedglob 2> /dev/null setopt localoptions noglobsubst noposixbuiltins pipefail no_aliases noglob nobash_rematch 2> /dev/null
# Ensure the module is loaded if not already, and the required features, such # Ensure the module is loaded if not already, and the required features, such
# as the associative 'history' array, which maps event numbers to full history # as the associative 'history' array, which maps event numbers to full history
# lines, are set. Also, make sure Perl is installed for multi-line output. # lines, are set. Also, make sure Perl is installed for multi-line output.
if zmodload -F zsh/parameter p:{commands,history} 2>/dev/null && (( ${+commands[perl]} )); then if zmodload -F zsh/parameter p:{commands,history} 2>/dev/null && (( ${+commands[perl]} )); then
selected="$(printf '%s\t%s\000' "${(kv)history[@]}" | selected="$(printf '%s\t%s\000' "${(kv)history[@]}" |
perl -0 -ne 'if (!$seen{(/^\s*[0-9]+\**\t(.*)/s, $1)}++) { s/\n/\n\t/g; print; }' | perl -0 -ne 'if (!$seen{(/^\s*[0-9]+\**\t(.*)/s, $1)}++) { s/\n/\n\t/g; print; }' |
FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '\t↳ ' --highlight-line --multi ${FZF_CTRL_R_OPTS-} --query=${(qqq)LBUFFER} --read0") \ FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '\t↳ ' --highlight-line ${FZF_CTRL_R_OPTS-} --query=${(qqq)LBUFFER} +m --read0") \
FZF_DEFAULT_OPTS_FILE='' $(__fzfcmd))" FZF_DEFAULT_OPTS_FILE='' $(__fzfcmd))"
extracted_with_perl=1
else else
selected="$(fc -rl 1 | __fzf_exec_awk '{ cmd=$0; sub(/^[ \t]*[0-9]+\**[ \t]+/, "", cmd); if (!seen[cmd]++) print $0 }' | selected="$(fc -rl 1 | __fzf_exec_awk '{ cmd=$0; sub(/^[ \t]*[0-9]+\**[ \t]+/, "", cmd); if (!seen[cmd]++) print $0 }' |
FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '\t↳ ' --highlight-line --multi ${FZF_CTRL_R_OPTS-} --query=${(qqq)LBUFFER}") \ FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '\t↳ ' --highlight-line ${FZF_CTRL_R_OPTS-} --query=${(qqq)LBUFFER} +m") \
FZF_DEFAULT_OPTS_FILE='' $(__fzfcmd))" FZF_DEFAULT_OPTS_FILE='' $(__fzfcmd))"
fi fi
local ret=$? local ret=$?
local -a cmds
# Avoid leaking auto assigned values when using backreferences '(#b)'
local -a mbegin mend match
if [ -n "$selected" ]; then if [ -n "$selected" ]; then
# Heuristic to check if the selected value is from history or a custom query if [[ $(__fzf_exec_awk '{print $1; exit}' <<< "$selected") =~ ^[1-9][0-9]* ]]; then
if ((( extracted_with_perl )) && [[ $selected == <->$'\t'* ]]) || zle vi-fetch-history -n $MATCH
((( ! 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 else # selected is a custom query, not from history
LBUFFER="$selected" LBUFFER="$selected"
fi fi
+1 -1
View File
@@ -1,6 +1,6 @@
The MIT License (MIT) The MIT License (MIT)
Copyright (c) 2013-2026 Junegunn Choi Copyright (c) 2013-2025 Junegunn Choi
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+150 -159
View File
@@ -30,168 +30,159 @@ func _() {
_ = x[actChangeBorderLabel-19] _ = x[actChangeBorderLabel-19]
_ = x[actChangeGhost-20] _ = x[actChangeGhost-20]
_ = x[actChangeHeader-21] _ = x[actChangeHeader-21]
_ = x[actChangeHeaderLines-22] _ = x[actChangeFooter-22]
_ = x[actChangeFooter-23] _ = x[actChangeHeaderLabel-23]
_ = x[actChangeHeaderLabel-24] _ = x[actChangeFooterLabel-24]
_ = x[actChangeFooterLabel-25] _ = x[actChangeInputLabel-25]
_ = x[actChangeInputLabel-26] _ = x[actChangeListLabel-26]
_ = x[actChangeListLabel-27] _ = x[actChangeMulti-27]
_ = x[actChangeMulti-28] _ = x[actChangeNth-28]
_ = x[actChangeNth-29] _ = x[actChangePointer-29]
_ = x[actChangeWithNth-30] _ = x[actChangePreview-30]
_ = x[actChangePointer-31] _ = x[actChangePreviewLabel-31]
_ = x[actChangePreview-32] _ = x[actChangePreviewWindow-32]
_ = x[actChangePreviewLabel-33] _ = x[actChangePrompt-33]
_ = x[actChangePreviewWindow-34] _ = x[actChangeQuery-34]
_ = x[actChangePrompt-35] _ = x[actClearScreen-35]
_ = x[actChangeQuery-36] _ = x[actClearQuery-36]
_ = x[actClearScreen-37] _ = x[actClearSelection-37]
_ = x[actClearQuery-38] _ = x[actClose-38]
_ = x[actClearSelection-39] _ = x[actDeleteChar-39]
_ = x[actClose-40] _ = x[actDeleteCharEof-40]
_ = x[actDeleteChar-41] _ = x[actEndOfLine-41]
_ = x[actDeleteCharEof-42] _ = x[actFatal-42]
_ = x[actEndOfLine-43] _ = x[actForwardChar-43]
_ = x[actFatal-44] _ = x[actForwardWord-44]
_ = x[actForwardChar-45] _ = x[actForwardSubWord-45]
_ = x[actForwardWord-46] _ = x[actKillLine-46]
_ = x[actForwardSubWord-47] _ = x[actKillWord-47]
_ = x[actKillLine-48] _ = x[actKillSubWord-48]
_ = x[actKillWord-49] _ = x[actUnixLineDiscard-49]
_ = x[actKillSubWord-50] _ = x[actUnixWordRubout-50]
_ = x[actUnixLineDiscard-51] _ = x[actYank-51]
_ = x[actUnixWordRubout-52] _ = x[actBackwardKillWord-52]
_ = x[actYank-53] _ = x[actBackwardKillSubWord-53]
_ = x[actBackwardKillWord-54] _ = x[actSelectAll-54]
_ = x[actBackwardKillSubWord-55] _ = x[actDeselectAll-55]
_ = x[actSelectAll-56] _ = x[actToggle-56]
_ = x[actDeselectAll-57] _ = x[actToggleSearch-57]
_ = x[actToggle-58] _ = x[actToggleAll-58]
_ = x[actToggleSearch-59] _ = x[actToggleDown-59]
_ = x[actToggleAll-60] _ = x[actToggleUp-60]
_ = x[actToggleDown-61] _ = x[actToggleIn-61]
_ = x[actToggleUp-62] _ = x[actToggleOut-62]
_ = x[actToggleIn-63] _ = x[actToggleTrack-63]
_ = x[actToggleOut-64] _ = x[actToggleTrackCurrent-64]
_ = x[actToggleTrack-65] _ = x[actToggleHeader-65]
_ = x[actToggleTrackCurrent-66] _ = x[actToggleWrap-66]
_ = x[actToggleHeader-67] _ = x[actToggleMultiLine-67]
_ = x[actToggleWrap-68] _ = x[actToggleHscroll-68]
_ = x[actToggleWrapWord-69] _ = x[actToggleRaw-69]
_ = x[actToggleMultiLine-70] _ = x[actEnableRaw-70]
_ = x[actToggleHscroll-71] _ = x[actDisableRaw-71]
_ = x[actToggleRaw-72] _ = x[actTrackCurrent-72]
_ = x[actEnableRaw-73] _ = x[actToggleInput-73]
_ = x[actDisableRaw-74] _ = x[actHideInput-74]
_ = x[actTrackCurrent-75] _ = x[actShowInput-75]
_ = x[actToggleInput-76] _ = x[actUntrackCurrent-76]
_ = x[actHideInput-77] _ = x[actDown-77]
_ = x[actShowInput-78] _ = x[actDownMatch-78]
_ = x[actUntrackCurrent-79] _ = x[actUp-79]
_ = x[actDown-80] _ = x[actUpMatch-80]
_ = x[actDownMatch-81] _ = x[actPageUp-81]
_ = x[actUp-82] _ = x[actPageDown-82]
_ = x[actUpMatch-83] _ = x[actPosition-83]
_ = x[actPageUp-84] _ = x[actHalfPageUp-84]
_ = x[actPageDown-85] _ = x[actHalfPageDown-85]
_ = x[actPosition-86] _ = x[actOffsetUp-86]
_ = x[actHalfPageUp-87] _ = x[actOffsetDown-87]
_ = x[actHalfPageDown-88] _ = x[actOffsetMiddle-88]
_ = x[actOffsetUp-89] _ = x[actJump-89]
_ = x[actOffsetDown-90] _ = x[actJumpAccept-90]
_ = x[actOffsetMiddle-91] _ = x[actPrintQuery-91]
_ = x[actJump-92] _ = x[actRefreshPreview-92]
_ = x[actJumpAccept-93] _ = x[actReplaceQuery-93]
_ = x[actPrintQuery-94] _ = x[actToggleSort-94]
_ = x[actRefreshPreview-95] _ = x[actShowPreview-95]
_ = x[actReplaceQuery-96] _ = x[actHidePreview-96]
_ = x[actToggleSort-97] _ = x[actTogglePreview-97]
_ = x[actShowPreview-98] _ = x[actTogglePreviewWrap-98]
_ = x[actHidePreview-99] _ = x[actTransform-99]
_ = x[actTogglePreview-100] _ = x[actTransformBorderLabel-100]
_ = x[actTogglePreviewWrap-101] _ = x[actTransformGhost-101]
_ = x[actTogglePreviewWrapWord-102] _ = x[actTransformHeader-102]
_ = x[actTransform-103] _ = x[actTransformFooter-103]
_ = x[actTransformBorderLabel-104] _ = x[actTransformHeaderLabel-104]
_ = x[actTransformGhost-105] _ = x[actTransformFooterLabel-105]
_ = x[actTransformHeader-106] _ = x[actTransformInputLabel-106]
_ = x[actTransformHeaderLines-107] _ = x[actTransformListLabel-107]
_ = x[actTransformFooter-108] _ = x[actTransformNth-108]
_ = x[actTransformHeaderLabel-109] _ = x[actTransformPointer-109]
_ = x[actTransformFooterLabel-110] _ = x[actTransformPreviewLabel-110]
_ = x[actTransformInputLabel-111] _ = x[actTransformPrompt-111]
_ = x[actTransformListLabel-112] _ = x[actTransformQuery-112]
_ = x[actTransformNth-113] _ = x[actTransformSearch-113]
_ = x[actTransformWithNth-114] _ = x[actTrigger-114]
_ = x[actTransformPointer-115] _ = x[actBgTransform-115]
_ = x[actTransformPreviewLabel-116] _ = x[actBgTransformBorderLabel-116]
_ = x[actTransformPrompt-117] _ = x[actBgTransformGhost-117]
_ = x[actTransformQuery-118] _ = x[actBgTransformHeader-118]
_ = x[actTransformSearch-119] _ = x[actBgTransformFooter-119]
_ = x[actTrigger-120] _ = x[actBgTransformHeaderLabel-120]
_ = x[actBgTransform-121] _ = x[actBgTransformFooterLabel-121]
_ = x[actBgTransformBorderLabel-122] _ = x[actBgTransformInputLabel-122]
_ = x[actBgTransformGhost-123] _ = x[actBgTransformListLabel-123]
_ = x[actBgTransformHeader-124] _ = x[actBgTransformNth-124]
_ = x[actBgTransformHeaderLines-125] _ = x[actBgTransformPointer-125]
_ = x[actBgTransformFooter-126] _ = x[actBgTransformPreviewLabel-126]
_ = x[actBgTransformHeaderLabel-127] _ = x[actBgTransformPrompt-127]
_ = x[actBgTransformFooterLabel-128] _ = x[actBgTransformQuery-128]
_ = x[actBgTransformInputLabel-129] _ = x[actBgTransformSearch-129]
_ = x[actBgTransformListLabel-130] _ = x[actBgCancel-130]
_ = x[actBgTransformNth-131] _ = x[actSearch-131]
_ = x[actBgTransformWithNth-132] _ = x[actPreview-132]
_ = x[actBgTransformPointer-133] _ = x[actPreviewTop-133]
_ = x[actBgTransformPreviewLabel-134] _ = x[actPreviewBottom-134]
_ = x[actBgTransformPrompt-135] _ = x[actPreviewUp-135]
_ = x[actBgTransformQuery-136] _ = x[actPreviewDown-136]
_ = x[actBgTransformSearch-137] _ = x[actPreviewPageUp-137]
_ = x[actBgCancel-138] _ = x[actPreviewPageDown-138]
_ = x[actSearch-139] _ = x[actPreviewHalfPageUp-139]
_ = x[actPreview-140] _ = x[actPreviewHalfPageDown-140]
_ = x[actPreviewTop-141] _ = x[actPrevHistory-141]
_ = x[actPreviewBottom-142] _ = x[actPrevSelected-142]
_ = x[actPreviewUp-143] _ = x[actPrint-143]
_ = x[actPreviewDown-144] _ = x[actPut-144]
_ = x[actPreviewPageUp-145] _ = x[actNextHistory-145]
_ = x[actPreviewPageDown-146] _ = x[actNextSelected-146]
_ = x[actPreviewHalfPageUp-147] _ = x[actExecute-147]
_ = x[actPreviewHalfPageDown-148] _ = x[actExecuteSilent-148]
_ = x[actPrevHistory-149] _ = x[actExecuteMulti-149]
_ = x[actPrevSelected-150] _ = x[actSigStop-150]
_ = x[actPrint-151] _ = x[actBest-151]
_ = x[actPut-152] _ = x[actFirst-152]
_ = x[actNextHistory-153] _ = x[actLast-153]
_ = x[actNextSelected-154] _ = x[actReload-154]
_ = x[actExecute-155] _ = x[actReloadSync-155]
_ = x[actExecuteSilent-156] _ = x[actDisableSearch-156]
_ = x[actExecuteMulti-157] _ = x[actEnableSearch-157]
_ = x[actSigStop-158] _ = x[actSelect-158]
_ = x[actBest-159] _ = x[actDeselect-159]
_ = x[actFirst-160] _ = x[actUnbind-160]
_ = x[actLast-161] _ = x[actRebind-161]
_ = x[actReload-162] _ = x[actToggleBind-162]
_ = x[actReloadSync-163] _ = x[actBecome-163]
_ = x[actDisableSearch-164] _ = x[actShowHeader-164]
_ = x[actEnableSearch-165] _ = x[actHideHeader-165]
_ = x[actSelect-166] _ = x[actBell-166]
_ = x[actDeselect-167] _ = x[actExclude-167]
_ = x[actUnbind-168] _ = x[actExcludeMulti-168]
_ = x[actRebind-169] _ = x[actAsync-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]
_ = x[actWait-178]
} }
const _actionType_name = "actIgnoreactStartactClickactInvalidactBracketedPasteBeginactBracketedPasteEndactCharactMouseactBeginningOfLineactAbortactAcceptactAcceptNonEmptyactAcceptOrPrintQueryactBackwardCharactBackwardDeleteCharactBackwardDeleteCharEofactBackwardWordactBackwardSubWordactCancelactChangeBorderLabelactChangeGhostactChangeHeaderactChangeHeaderLinesactChangeFooteractChangeHeaderLabelactChangeFooterLabelactChangeInputLabelactChangeListLabelactChangeMultiactChangeNthactChangeWithNthactChangePointeractChangePreviewactChangePreviewLabelactChangePreviewWindowactChangePromptactChangeQueryactClearScreenactClearQueryactClearSelectionactCloseactDeleteCharactDeleteCharEofactEndOfLineactFatalactForwardCharactForwardWordactForwardSubWordactKillLineactKillWordactKillSubWordactUnixLineDiscardactUnixWordRuboutactYankactBackwardKillWordactBackwardKillSubWordactSelectAllactDeselectAllactToggleactToggleSearchactToggleAllactToggleDownactToggleUpactToggleInactToggleOutactToggleTrackactToggleTrackCurrentactToggleHeaderactToggleWrapactToggleWrapWordactToggleMultiLineactToggleHscrollactToggleRawactEnableRawactDisableRawactTrackCurrentactToggleInputactHideInputactShowInputactUntrackCurrentactDownactDownMatchactUpactUpMatchactPageUpactPageDownactPositionactHalfPageUpactHalfPageDownactOffsetUpactOffsetDownactOffsetMiddleactJumpactJumpAcceptactPrintQueryactRefreshPreviewactReplaceQueryactToggleSortactShowPreviewactHidePreviewactTogglePreviewactTogglePreviewWrapactTogglePreviewWrapWordactTransformactTransformBorderLabelactTransformGhostactTransformHeaderactTransformHeaderLinesactTransformFooteractTransformHeaderLabelactTransformFooterLabelactTransformInputLabelactTransformListLabelactTransformNthactTransformWithNthactTransformPointeractTransformPreviewLabelactTransformPromptactTransformQueryactTransformSearchactTriggeractBgTransformactBgTransformBorderLabelactBgTransformGhostactBgTransformHeaderactBgTransformHeaderLinesactBgTransformFooteractBgTransformHeaderLabelactBgTransformFooterLabelactBgTransformInputLabelactBgTransformListLabelactBgTransformNthactBgTransformWithNthactBgTransformPointeractBgTransformPreviewLabelactBgTransformPromptactBgTransformQueryactBgTransformSearchactBgCancelactSearchactPreviewactPreviewTopactPreviewBottomactPreviewUpactPreviewDownactPreviewPageUpactPreviewPageDownactPreviewHalfPageUpactPreviewHalfPageDownactPrevHistoryactPrevSelectedactPrintactPutactNextHistoryactNextSelectedactExecuteactExecuteSilentactExecuteMultiactSigStopactBestactFirstactLastactReloadactReloadSyncactDisableSearchactEnableSearchactSelectactDeselectactUnbindactRebindactToggleBindactBecomeactShowHeaderactHideHeaderactBellactExcludeactExcludeMultiactAsyncactWait" const _actionType_name = "actIgnoreactStartactClickactInvalidactBracketedPasteBeginactBracketedPasteEndactCharactMouseactBeginningOfLineactAbortactAcceptactAcceptNonEmptyactAcceptOrPrintQueryactBackwardCharactBackwardDeleteCharactBackwardDeleteCharEofactBackwardWordactBackwardSubWordactCancelactChangeBorderLabelactChangeGhostactChangeHeaderactChangeFooteractChangeHeaderLabelactChangeFooterLabelactChangeInputLabelactChangeListLabelactChangeMultiactChangeNthactChangePointeractChangePreviewactChangePreviewLabelactChangePreviewWindowactChangePromptactChangeQueryactClearScreenactClearQueryactClearSelectionactCloseactDeleteCharactDeleteCharEofactEndOfLineactFatalactForwardCharactForwardWordactForwardSubWordactKillLineactKillWordactKillSubWordactUnixLineDiscardactUnixWordRuboutactYankactBackwardKillWordactBackwardKillSubWordactSelectAllactDeselectAllactToggleactToggleSearchactToggleAllactToggleDownactToggleUpactToggleInactToggleOutactToggleTrackactToggleTrackCurrentactToggleHeaderactToggleWrapactToggleMultiLineactToggleHscrollactToggleRawactEnableRawactDisableRawactTrackCurrentactToggleInputactHideInputactShowInputactUntrackCurrentactDownactDownMatchactUpactUpMatchactPageUpactPageDownactPositionactHalfPageUpactHalfPageDownactOffsetUpactOffsetDownactOffsetMiddleactJumpactJumpAcceptactPrintQueryactRefreshPreviewactReplaceQueryactToggleSortactShowPreviewactHidePreviewactTogglePreviewactTogglePreviewWrapactTransformactTransformBorderLabelactTransformGhostactTransformHeaderactTransformFooteractTransformHeaderLabelactTransformFooterLabelactTransformInputLabelactTransformListLabelactTransformNthactTransformPointeractTransformPreviewLabelactTransformPromptactTransformQueryactTransformSearchactTriggeractBgTransformactBgTransformBorderLabelactBgTransformGhostactBgTransformHeaderactBgTransformFooteractBgTransformHeaderLabelactBgTransformFooterLabelactBgTransformInputLabelactBgTransformListLabelactBgTransformNthactBgTransformPointeractBgTransformPreviewLabelactBgTransformPromptactBgTransformQueryactBgTransformSearchactBgCancelactSearchactPreviewactPreviewTopactPreviewBottomactPreviewUpactPreviewDownactPreviewPageUpactPreviewPageDownactPreviewHalfPageUpactPreviewHalfPageDownactPrevHistoryactPrevSelectedactPrintactPutactNextHistoryactNextSelectedactExecuteactExecuteSilentactExecuteMultiactSigStopactBestactFirstactLastactReloadactReloadSyncactDisableSearchactEnableSearchactSelectactDeselectactUnbindactRebindactToggleBindactBecomeactShowHeaderactHideHeaderactBellactExcludeactExcludeMultiactAsync"
var _actionType_index = [...]uint16{0, 9, 17, 25, 35, 57, 77, 84, 92, 110, 118, 127, 144, 165, 180, 201, 225, 240, 258, 267, 287, 301, 316, 336, 351, 371, 391, 410, 428, 442, 454, 470, 486, 502, 523, 545, 560, 574, 588, 601, 618, 626, 639, 655, 667, 675, 689, 703, 720, 731, 742, 756, 774, 791, 798, 817, 839, 851, 865, 874, 889, 901, 914, 925, 936, 948, 962, 983, 998, 1011, 1028, 1046, 1062, 1074, 1086, 1099, 1114, 1128, 1140, 1152, 1169, 1176, 1188, 1193, 1203, 1212, 1223, 1234, 1247, 1262, 1273, 1286, 1301, 1308, 1321, 1334, 1351, 1366, 1379, 1393, 1407, 1423, 1443, 1467, 1479, 1502, 1519, 1537, 1560, 1578, 1601, 1624, 1646, 1667, 1682, 1701, 1720, 1744, 1762, 1779, 1797, 1807, 1821, 1846, 1865, 1885, 1910, 1930, 1955, 1980, 2004, 2027, 2044, 2065, 2086, 2112, 2132, 2151, 2171, 2182, 2191, 2201, 2214, 2230, 2242, 2256, 2272, 2290, 2310, 2332, 2346, 2361, 2369, 2375, 2389, 2404, 2414, 2430, 2445, 2455, 2462, 2470, 2477, 2486, 2499, 2515, 2530, 2539, 2550, 2559, 2568, 2581, 2590, 2603, 2616, 2623, 2633, 2648, 2656, 2663} 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}
func (i actionType) String() string { func (i actionType) String() string {
if i < 0 || i >= actionType(len(_actionType_index)-1) { if i < 0 || i >= actionType(len(_actionType_index)-1) {
-99
View File
@@ -1,99 +0,0 @@
# 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 0256, 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.
+30 -28
View File
@@ -266,7 +266,7 @@ func charClassOf(char rune) charClass {
} }
func bonusFor(prevClass charClass, class charClass) int16 { func bonusFor(prevClass charClass, class charClass) int16 {
if class >= charNonWord { if class > charNonWord {
switch prevClass { switch prevClass {
case charWhite: case charWhite:
// Word boundary after whitespace // Word boundary after whitespace
@@ -321,15 +321,22 @@ type Algo func(caseSensitive bool, normalize bool, forward bool, input *util.Cha
func trySkip(input *util.Chars, caseSensitive bool, b byte, from int) int { func trySkip(input *util.Chars, caseSensitive bool, b byte, from int) int {
byteArray := input.Bytes()[from:] byteArray := input.Bytes()[from:]
// For case-insensitive search of a letter, search for both cases in one pass
if !caseSensitive && b >= 'a' && b <= 'z' {
idx := IndexByteTwo(byteArray, b, b-32)
if idx < 0 {
return -1
}
return from + idx
}
idx := bytes.IndexByte(byteArray, b) 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.
if !caseSensitive && b >= 'a' && b <= 'z' {
if idx > 0 {
byteArray = byteArray[:idx]
}
uidx := bytes.IndexByte(byteArray, b-32)
if uidx >= 0 {
idx = uidx
}
}
if idx < 0 { if idx < 0 {
return -1 return -1
} }
@@ -373,17 +380,14 @@ func asciiFuzzyIndex(input *util.Chars, pattern []rune, caseSensitive bool) (int
} }
// Find the last appearance of the last character of the pattern to limit the search scope // Find the last appearance of the last character of the pattern to limit the search scope
bu := b
if !caseSensitive && b >= 'a' && b <= 'z' {
bu = b - 32
}
scope := input.Bytes()[lastIdx:] scope := input.Bytes()[lastIdx:]
if len(scope) > 1 { for offset := len(scope) - 1; offset > 0; offset-- {
tail := scope[1:] if scope[offset] == b || scope[offset] == bu {
var end int return firstIdx, lastIdx + offset + 1
if !caseSensitive && b >= 'a' && b <= 'z' {
end = lastIndexByteTwo(tail, b, b-32)
} else {
end = bytes.LastIndexByte(tail, b)
}
if end >= 0 {
return firstIdx, lastIdx + 1 + end + 1
} }
} }
return firstIdx, lastIdx + 1 return firstIdx, lastIdx + 1
@@ -441,9 +445,7 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
// Since O(nm) algorithm can be prohibitively expensive for large input, // Since O(nm) algorithm can be prohibitively expensive for large input,
// we fall back to the greedy algorithm. // we fall back to the greedy algorithm.
// Also, we should not allow a very long pattern to avoid 16-bit integer if slab != nil && N*M > cap(slab.I16) {
// 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) return FuzzyMatchV1(caseSensitive, normalize, forward, input, pattern, withPos, slab)
} }
@@ -499,7 +501,7 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
if pidx < M { if pidx < M {
F[pidx] = int32(off) F[pidx] = int32(off)
pidx++ pidx++
pchar = pattern[min(pidx, M-1)] pchar = pattern[util.Min(pidx, M-1)]
} }
lastIdx = off lastIdx = off
} }
@@ -517,9 +519,9 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
inGap = false inGap = false
} else { } else {
if inGap { if inGap {
H0[off] = max(prevH0+scoreGapExtension, 0) H0[off] = util.Max16(prevH0+scoreGapExtension, 0)
} else { } else {
H0[off] = max(prevH0+scoreGapStart, 0) H0[off] = util.Max16(prevH0+scoreGapStart, 0)
} }
C0[off] = 0 C0[off] = 0
inGap = true inGap = true
@@ -585,7 +587,7 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
if b >= bonusBoundary && b > fb { if b >= bonusBoundary && b > fb {
consecutive = 1 consecutive = 1
} else { } else {
b = max(b, bonusConsecutive, fb) b = util.Max16(b, util.Max16(bonusConsecutive, fb))
} }
} }
if s1+b < s2 { if s1+b < s2 {
@@ -598,7 +600,7 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
Csub[off] = consecutive Csub[off] = consecutive
inGap = s1 < s2 inGap = s1 < s2
score := max(s1, s2, 0) score := util.Max16(util.Max16(s1, s2), 0)
if pidx == M-1 && (forward && score > maxScore || !forward && score >= maxScore) { if pidx == M-1 && (forward && score > maxScore || !forward && score >= maxScore) {
maxScore, maxScorePos = score, col maxScore, maxScorePos = score, col
} }
@@ -682,7 +684,7 @@ func calculateScore(caseSensitive bool, normalize bool, text *util.Chars, patter
if bonus >= bonusBoundary && bonus > firstBonus { if bonus >= bonusBoundary && bonus > firstBonus {
firstBonus = bonus firstBonus = bonus
} }
bonus = max(bonus, firstBonus, bonusConsecutive) bonus = util.Max16(util.Max16(bonus, firstBonus), bonusConsecutive)
} }
if pidx == 0 { if pidx == 0 {
score += int(bonus * bonusFirstCharMultiplier) score += int(bonus * bonusFirstCharMultiplier)
+1 -10
View File
@@ -57,15 +57,6 @@ func TestFuzzyMatch(t *testing.T) {
scoreMatch*4+int(bonusBoundaryDelimiter)*bonusFirstCharMultiplier+int(bonusBoundaryDelimiter)*3) scoreMatch*4+int(bonusBoundaryDelimiter)*bonusFirstCharMultiplier+int(bonusBoundaryDelimiter)*3)
assertMatch(t, fn, false, forward, "/.oh-my-zsh/cache", "zshc", 8, 13, assertMatch(t, fn, false, forward, "/.oh-my-zsh/cache", "zshc", 8, 13,
scoreMatch*4+bonusBoundary*bonusFirstCharMultiplier+bonusBoundary*2+scoreGapStart+int(bonusBoundaryDelimiter)) scoreMatch*4+bonusBoundary*bonusFirstCharMultiplier+bonusBoundary*2+scoreGapStart+int(bonusBoundaryDelimiter))
// Non-word character at start of input is treated as a strong boundary
assertMatch(t, fn, false, forward, ".vimrc", ".vimrc", 0, 6,
scoreMatch*6+int(bonusBoundaryWhite)*(bonusFirstCharMultiplier+5))
// Non-word character right after a delimiter inherits the delimiter boundary
assertMatch(t, fn, false, forward, "/.vimrc", ".vimrc", 1, 7,
scoreMatch*6+int(bonusBoundaryDelimiter)*(bonusFirstCharMultiplier+5))
// Non-word character in the middle of a word stays at bonusNonWord
assertMatch(t, fn, false, forward, "a.vimrc", ".vimrc", 1, 7,
scoreMatch*6+bonusBoundary*(bonusFirstCharMultiplier+5))
assertMatch(t, fn, false, forward, "ab0123 456", "12356", 3, 10, assertMatch(t, fn, false, forward, "ab0123 456", "12356", 3, 10,
scoreMatch*5+bonusConsecutive*3+scoreGapStart+scoreGapExtension) scoreMatch*5+bonusConsecutive*3+scoreGapStart+scoreGapExtension)
assertMatch(t, fn, false, forward, "abc123 456", "12356", 3, 10, assertMatch(t, fn, false, forward, "abc123 456", "12356", 3, 10,
@@ -95,7 +86,7 @@ func TestFuzzyMatch(t *testing.T) {
scoreGapStart*2+scoreGapExtension*2) scoreGapStart*2+scoreGapExtension*2)
assertMatch(t, fn, true, forward, "FooBar Baz", "FooB", 0, 4, assertMatch(t, fn, true, forward, "FooBar Baz", "FooB", 0, 4,
scoreMatch*4+int(bonusBoundaryWhite)*bonusFirstCharMultiplier+int(bonusBoundaryWhite)*2+ scoreMatch*4+int(bonusBoundaryWhite)*bonusFirstCharMultiplier+int(bonusBoundaryWhite)*2+
max(bonusCamel123, int(bonusBoundaryWhite))) util.Max(bonusCamel123, int(bonusBoundaryWhite)))
// Consecutive bonus updated // Consecutive bonus updated
assertMatch(t, fn, true, forward, "foo-bar", "o-ba", 2, 6, assertMatch(t, fn, true, forward, "foo-bar", "o-ba", 2, 6,
-24
View File
@@ -1,24 +0,0 @@
//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
-377
View File
@@ -1,377 +0,0 @@
#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
-17
View File
@@ -1,17 +0,0 @@
//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
-249
View File
@@ -1,249 +0,0 @@
#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
-33
View File
@@ -1,33 +0,0 @@
//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
}
-259
View File
@@ -1,259 +0,0 @@
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) }
+33 -98
View File
@@ -6,7 +6,6 @@ import (
"strings" "strings"
"unicode/utf8" "unicode/utf8"
"github.com/junegunn/fzf/src/algo"
"github.com/junegunn/fzf/src/tui" "github.com/junegunn/fzf/src/tui"
) )
@@ -23,21 +22,20 @@ type url struct {
type ansiState struct { type ansiState struct {
fg tui.Color fg tui.Color
bg tui.Color bg tui.Color
ul tui.Color
attr tui.Attr attr tui.Attr
lbg tui.Color lbg tui.Color
url *url url *url
} }
func (s *ansiState) colored() bool { func (s *ansiState) colored() bool {
return s.fg != -1 || s.bg != -1 || s.ul != -1 || s.attr > 0 || s.lbg >= 0 || s.url != nil return s.fg != -1 || s.bg != -1 || s.attr > 0 || s.lbg >= 0 || s.url != nil
} }
func (s *ansiState) equals(t *ansiState) bool { func (s *ansiState) equals(t *ansiState) bool {
if t == nil { if t == nil {
return !s.colored() return !s.colored()
} }
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 return s.fg == t.fg && s.bg == t.bg && s.attr == t.attr && s.lbg == t.lbg && s.url == t.url
} }
func (s *ansiState) ToString() string { func (s *ansiState) ToString() string {
@@ -56,18 +54,7 @@ func (s *ansiState) ToString() string {
ret += "3;" ret += "3;"
} }
if s.attr&tui.Underline > 0 { if s.attr&tui.Underline > 0 {
switch s.attr.UnderlineStyle() { ret += "4;"
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 { if s.attr&tui.Blink > 0 {
ret += "5;" ret += "5;"
@@ -79,9 +66,6 @@ func (s *ansiState) ToString() string {
ret += "9;" ret += "9;"
} }
ret += toAnsiString(s.fg, 30) + toAnsiString(s.bg, 40) ret += toAnsiString(s.fg, 30) + toAnsiString(s.bg, 40)
if s.ul != -1 {
ret += toAnsiStringUl(s.ul)
}
ret = "\x1b[" + strings.TrimSuffix(ret, ";") + "m" ret = "\x1b[" + strings.TrimSuffix(ret, ";") + "m"
if s.url != nil { if s.url != nil {
@@ -90,20 +74,6 @@ func (s *ansiState) ToString() string {
return ret 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 { func toAnsiString(color tui.Color, offset int) string {
col := int(color) col := int(color)
ret := "" ret := ""
@@ -124,31 +94,31 @@ func toAnsiString(color tui.Color, offset int) string {
return ret + ";" return ret + ";"
} }
func isPrint(c uint8) bool {
return '\x20' <= c && c <= '\x7e'
}
func matchOperatingSystemCommand(s string, start int) int { func matchOperatingSystemCommand(s string, start int) int {
// `\x1b][0-9][;:][[:print:]]+(?:\x1b\\\\|\x07)` // `\x1b][0-9][;:][[:print:]]+(?:\x1b\\\\|\x07)`
// ^ match starting here after the first printable character // ^ match starting here after the first printable character
// //
i := start // prefix matched in nextAnsiEscapeSequence() i := start // prefix matched in nextAnsiEscapeSequence()
for ; i < len(s) && isPrint(s[i]); i++ {
// Find the terminator: BEL (\x07) or ESC (\x1b) for ST (\x1b\\)
idx := algo.IndexByteTwo(stringBytes(s[i:]), '\x07', '\x1b')
if idx < 0 {
return -1
} }
i += idx if i < len(s) {
if s[i] == '\x07' {
if s[i] == '\x07' { return i + 1
return i + 1 }
} // `\x1b]8;PARAMS;URI\x1b\\TITLE\x1b]8;;\x1b`
// `\x1b]8;PARAMS;URI\x1b\\TITLE\x1b]8;;\x1b` // ------
// ------ if s[i] == '\x1b' && i < len(s)-1 && s[i+1] == '\\' {
if i < len(s)-1 && s[i+1] == '\\' { return i + 2
return i + 2 }
} }
// `\x1b]8;PARAMS;URI\x1b\\TITLE\x1b]8;;\x1b` // `\x1b]8;PARAMS;URI\x1b\\TITLE\x1b]8;;\x1b`
// ------------ // ------------
if s[:i+1] == "\x1b]8;;\x1b" { if i < len(s) && s[:i+1] == "\x1b]8;;\x1b" {
return i + 1 return i + 1
} }
@@ -234,7 +204,7 @@ Loop:
// \x1b][0-9]+[;:][[:print:]]+(?:\x1b\\\\|\x07) // \x1b][0-9]+[;:][[:print:]]+(?:\x1b\\\\|\x07)
// --------------- // ---------------
if j > 2 && i+j+1 < len(s) && (s[i+j] == ';' || s[i+j] == ':') && s[i+j+1] >= '\x20' { if j > 2 && i+j+1 < len(s) && (s[i+j] == ';' || s[i+j] == ':') && isPrint(s[i+j+1]) {
if k := matchOperatingSystemCommand(s[i:], j+2); k != -1 { if k := matchOperatingSystemCommand(s[i:], j+2); k != -1 {
return i, i + k return i, i + k
} }
@@ -368,19 +338,15 @@ func extractColor(str string, state *ansiState, proc func(string, *ansiState) bo
return trimmed, nil, state return trimmed, nil, state
} }
func parseAnsiCode(s string) (int, byte, string) { func parseAnsiCode(s string) (int, string) {
var remaining string var remaining string
var sep byte var i int
// Find the first separator (either ; or :) // Faster than strings.IndexAny(";:")
i := -1 i = strings.IndexByte(s, ';')
for j := 0; j < len(s); j++ { if i < 0 {
if s[j] == ';' || s[j] == ':' { i = strings.IndexByte(s, ':')
i = j
break
}
} }
if i >= 0 { if i >= 0 {
sep = s[i]
remaining = s[i+1:] remaining = s[i+1:]
s = s[:i] s = s[:i]
} }
@@ -392,14 +358,14 @@ func parseAnsiCode(s string) (int, byte, string) {
for _, ch := range stringBytes(s) { for _, ch := range stringBytes(s) {
ch -= '0' ch -= '0'
if ch > 9 { if ch > 9 {
return -1, sep, remaining return -1, remaining
} }
code = code*10 + int(ch) code = code*10 + int(ch)
} }
return code, sep, remaining return code, remaining
} }
return -1, sep, remaining return -1, remaining
} }
func interpretCode(ansiCode string, prevState *ansiState) ansiState { func interpretCode(ansiCode string, prevState *ansiState) ansiState {
@@ -407,14 +373,14 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
if prevState != nil { if prevState != nil {
return *prevState return *prevState
} }
return ansiState{-1, -1, -1, 0, -1, nil} return ansiState{-1, -1, 0, -1, nil}
} }
var state ansiState var state ansiState
if prevState == nil { if prevState == nil {
state = ansiState{-1, -1, -1, 0, -1, nil} state = ansiState{-1, -1, 0, -1, nil}
} else { } else {
state = ansiState{prevState.fg, prevState.bg, prevState.ul, prevState.attr, prevState.lbg, prevState.url} state = ansiState{prevState.fg, prevState.bg, prevState.attr, prevState.lbg, prevState.url}
} }
if ansiCode[0] != '\x1b' || ansiCode[1] != '[' || ansiCode[len(ansiCode)-1] != 'm' { if ansiCode[0] != '\x1b' || ansiCode[1] != '[' || ansiCode[len(ansiCode)-1] != 'm' {
if prevState != nil && (strings.HasSuffix(ansiCode, "0K") || strings.HasSuffix(ansiCode, "[K")) { if prevState != nil && (strings.HasSuffix(ansiCode, "0K") || strings.HasSuffix(ansiCode, "[K")) {
@@ -439,7 +405,6 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
reset := func() { reset := func() {
state.fg = -1 state.fg = -1
state.bg = -1 state.bg = -1
state.ul = -1
state.attr = 0 state.attr = 0
} }
@@ -455,8 +420,7 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
count := 0 count := 0
for len(ansiCode) != 0 { for len(ansiCode) != 0 {
var num int var num int
var sep byte if num, ansiCode = parseAnsiCode(ansiCode); num != -1 {
if num, sep, ansiCode = parseAnsiCode(ansiCode); num != -1 {
count++ count++
switch state256 { switch state256 {
case 0: case 0:
@@ -467,15 +431,10 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
case 48: case 48:
ptr = &state.bg ptr = &state.bg
state256++ state256++
case 58:
ptr = &state.ul
state256++
case 39: case 39:
state.fg = -1 state.fg = -1
case 49: case 49:
state.bg = -1 state.bg = -1
case 59:
state.ul = -1
case 1: case 1:
state.attr = state.attr | tui.Bold state.attr = state.attr | tui.Bold
case 2: case 2:
@@ -483,30 +442,7 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
case 3: case 3:
state.attr = state.attr | tui.Italic state.attr = state.attr | tui.Italic
case 4: case 4:
if sep == ':' { state.attr = state.attr | tui.Underline
// 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: case 5:
state.attr = state.attr | tui.Blink state.attr = state.attr | tui.Blink
case 7: case 7:
@@ -520,7 +456,6 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
state.attr = state.attr &^ tui.Italic state.attr = state.attr &^ tui.Italic
case 24: // tput rmul case 24: // tput rmul
state.attr = state.attr &^ tui.Underline state.attr = state.attr &^ tui.Underline
state.attr = state.attr &^ tui.UnderlineStyleMask
case 25: case 25:
state.attr = state.attr &^ tui.Blink state.attr = state.attr &^ tui.Blink
case 27: case 27:
+17 -123
View File
@@ -369,10 +369,10 @@ func TestAnsiCodeStringConversion(t *testing.T) {
} }
} }
assert("\x1b[m", nil, "") assert("\x1b[m", nil, "")
assert("\x1b[m", &ansiState{attr: tui.Blink, ul: -1, lbg: -1}, "") assert("\x1b[m", &ansiState{attr: tui.Blink, lbg: -1}, "")
assert("\x1b[0m", &ansiState{fg: 4, bg: 4, ul: -1, lbg: -1}, "") assert("\x1b[0m", &ansiState{fg: 4, bg: 4, lbg: -1}, "")
assert("\x1b[;m", &ansiState{fg: 4, bg: 4, ul: -1, lbg: -1}, "") assert("\x1b[;m", &ansiState{fg: 4, bg: 4, lbg: -1}, "")
assert("\x1b[;;m", &ansiState{fg: 4, bg: 4, ul: -1, lbg: -1}, "") assert("\x1b[;;m", &ansiState{fg: 4, bg: 4, lbg: -1}, "")
assert("\x1b[31m", nil, "\x1b[31;49m") assert("\x1b[31m", nil, "\x1b[31;49m")
assert("\x1b[41m", nil, "\x1b[39;41m") assert("\x1b[41m", nil, "\x1b[39;41m")
@@ -380,142 +380,36 @@ func TestAnsiCodeStringConversion(t *testing.T) {
assert("\x1b[92m", nil, "\x1b[92;49m") assert("\x1b[92m", nil, "\x1b[92;49m")
assert("\x1b[102m", nil, "\x1b[39;102m") assert("\x1b[102m", nil, "\x1b[39;102m")
assert("\x1b[31m", &ansiState{fg: 4, bg: 4, ul: -1, lbg: -1}, "\x1b[31;44m") assert("\x1b[31m", &ansiState{fg: 4, bg: 4, 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[1;2;31m", &ansiState{fg: 2, bg: -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[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;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;1m", nil, "\x1b[1;38;2;10;20;30;48;5;100m")
assert("\x1b[48;5;100;38;2;10;20;30;7m", assert("\x1b[48;5;100;38;2;10;20;30;7m",
&ansiState{attr: tui.Dim | tui.Italic, fg: 1, bg: 1, ul: -1}, &ansiState{attr: tui.Dim | tui.Italic, fg: 1, bg: 1},
"\x1b[2;3;7;38;2;10;20;30;48;5;100m") "\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) { func TestParseAnsiCode(t *testing.T) {
tests := []struct { tests := []struct {
In string In, Exp string
Exp string N int
N int
Sep byte
}{ }{
{"123", "", 123, 0}, {"123", "", 123},
{"1a", "", -1, 0}, {"1a", "", -1},
{"1a;12", "12", -1, ';'}, {"1a;12", "12", -1},
{"12;a", "a", 12, ';'}, {"12;a", "a", 12},
{"-2", "", -1, 0}, {"-2", "", -1},
// 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 { for _, x := range tests {
n, sep, s := parseAnsiCode(x.In) n, s := parseAnsiCode(x.In)
if n != x.N || s != x.Exp || sep != x.Sep { if n != x.N || s != x.Exp {
t.Fatalf("%q: got: (%d %q %q) want: (%d %q %q)", x.In, n, s, string(sep), x.N, x.Exp, string(x.Sep)) t.Fatalf("%q: got: (%d %q) want: (%d %q)", x.In, n, s, x.N, x.Exp)
} }
} }
} }
// 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 // kernel/bpf/preload/iterators/README
const ansiBenchmarkString = "\x1b[38;5;81m\x1b[01;31m\x1b[Kkernel/\x1b[0m\x1b[38:5:81mbpf/" + 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/" + "\x1b[0m\x1b[38:5:81mpreload/\x1b[0m\x1b[38;5;81miterators/" +
+15 -18
View File
@@ -2,13 +2,10 @@ package fzf
import "sync" import "sync"
// ChunkBitmap is a bitmap with one bit per item in a chunk. // queryCache associates strings to lists of items
type ChunkBitmap [chunkBitWords]uint64 type queryCache map[string][]Result
// queryCache associates query strings to bitmaps of matching items // ChunkCache associates Chunk and query string to lists of items
type queryCache map[string]ChunkBitmap
// ChunkCache associates Chunk and query string to bitmaps
type ChunkCache struct { type ChunkCache struct {
mutex sync.Mutex mutex sync.Mutex
cache map[*Chunk]*queryCache cache map[*Chunk]*queryCache
@@ -33,9 +30,9 @@ func (cc *ChunkCache) retire(chunk ...*Chunk) {
cc.mutex.Unlock() cc.mutex.Unlock()
} }
// Add stores the bitmap for the given chunk and key // Add adds the list to the cache
func (cc *ChunkCache) Add(chunk *Chunk, key string, bitmap ChunkBitmap, matchCount int) { func (cc *ChunkCache) Add(chunk *Chunk, key string, list []Result) {
if len(key) == 0 || !chunk.IsFull() || matchCount > queryCacheMax { if len(key) == 0 || !chunk.IsFull() || len(list) > queryCacheMax {
return return
} }
@@ -47,11 +44,11 @@ func (cc *ChunkCache) Add(chunk *Chunk, key string, bitmap ChunkBitmap, matchCou
cc.cache[chunk] = &queryCache{} cc.cache[chunk] = &queryCache{}
qc = cc.cache[chunk] qc = cc.cache[chunk]
} }
(*qc)[key] = bitmap (*qc)[key] = list
} }
// Lookup returns the bitmap for the exact key // Lookup is called to lookup ChunkCache
func (cc *ChunkCache) Lookup(chunk *Chunk, key string) *ChunkBitmap { func (cc *ChunkCache) Lookup(chunk *Chunk, key string) []Result {
if len(key) == 0 || !chunk.IsFull() { if len(key) == 0 || !chunk.IsFull() {
return nil return nil
} }
@@ -61,15 +58,15 @@ func (cc *ChunkCache) Lookup(chunk *Chunk, key string) *ChunkBitmap {
qc, ok := cc.cache[chunk] qc, ok := cc.cache[chunk]
if ok { if ok {
if bm, ok := (*qc)[key]; ok { list, ok := (*qc)[key]
return &bm if ok {
return list
} }
} }
return nil return nil
} }
// Search finds the bitmap for the longest prefix or suffix of the key func (cc *ChunkCache) Search(chunk *Chunk, key string) []Result {
func (cc *ChunkCache) Search(chunk *Chunk, key string) *ChunkBitmap {
if len(key) == 0 || !chunk.IsFull() { if len(key) == 0 || !chunk.IsFull() {
return nil return nil
} }
@@ -89,8 +86,8 @@ func (cc *ChunkCache) Search(chunk *Chunk, key string) *ChunkBitmap {
prefix := key[:len(key)-idx] prefix := key[:len(key)-idx]
suffix := key[idx:] suffix := key[idx:]
for _, substr := range [2]string{prefix, suffix} { for _, substr := range [2]string{prefix, suffix} {
if bm, found := (*qc)[substr]; found { if cached, found := (*qc)[substr]; found {
return &bm return cached
} }
} }
} }
+11 -11
View File
@@ -6,34 +6,34 @@ func TestChunkCache(t *testing.T) {
cache := NewChunkCache() cache := NewChunkCache()
chunk1p := &Chunk{} chunk1p := &Chunk{}
chunk2p := &Chunk{count: chunkSize} chunk2p := &Chunk{count: chunkSize}
bm1 := ChunkBitmap{1} items1 := []Result{{}}
bm2 := ChunkBitmap{1, 2} items2 := []Result{{}, {}}
cache.Add(chunk1p, "foo", bm1, 1) cache.Add(chunk1p, "foo", items1)
cache.Add(chunk2p, "foo", bm1, 1) cache.Add(chunk2p, "foo", items1)
cache.Add(chunk2p, "bar", bm2, 2) cache.Add(chunk2p, "bar", items2)
{ // chunk1 is not full { // chunk1 is not full
cached := cache.Lookup(chunk1p, "foo") cached := cache.Lookup(chunk1p, "foo")
if cached != nil { if cached != nil {
t.Error("Cached disabled for non-full chunks", cached) t.Error("Cached disabled for non-empty chunks", cached)
} }
} }
{ {
cached := cache.Lookup(chunk2p, "foo") cached := cache.Lookup(chunk2p, "foo")
if cached == nil || cached[0] != 1 { if cached == nil || len(cached) != 1 {
t.Error("Expected bitmap cached", cached) t.Error("Expected 1 item cached", cached)
} }
} }
{ {
cached := cache.Lookup(chunk2p, "bar") cached := cache.Lookup(chunk2p, "bar")
if cached == nil || cached[1] != 2 { if cached == nil || len(cached) != 2 {
t.Error("Expected bitmap cached", cached) t.Error("Expected 2 items cached", cached)
} }
} }
{ {
cached := cache.Lookup(chunk1p, "foobar") cached := cache.Lookup(chunk1p, "foobar")
if cached != nil { if cached != nil {
t.Error("Expected nil cached", cached) t.Error("Expected 0 item cached", cached)
} }
} }
} }
-29
View File
@@ -52,20 +52,6 @@ func (cl *ChunkList) lastChunk() *Chunk {
return cl.chunks[len(cl.chunks)-1] return cl.chunks[len(cl.chunks)-1]
} }
// GetItems returns the first n items from the given chunks
func GetItems(chunks []*Chunk, n int) []Item {
items := make([]Item, 0, n)
for _, chunk := range chunks {
for i := 0; i < chunk.count && len(items) < n; i++ {
items = append(items, chunk.items[i])
}
if len(items) >= n {
break
}
}
return items
}
// CountItems returns the total number of Items // CountItems returns the total number of Items
func CountItems(cs []*Chunk) int { func CountItems(cs []*Chunk) int {
if len(cs) == 0 { if len(cs) == 0 {
@@ -99,21 +85,6 @@ func (cl *ChunkList) Clear() {
cl.mutex.Unlock() cl.mutex.Unlock()
} }
// ForEachItem iterates all items and applies fn to each one.
// The done callback runs under the lock to safely update shared state.
func (cl *ChunkList) ForEachItem(fn func(*Item), done func()) {
cl.mutex.Lock()
for _, chunk := range cl.chunks {
for i := 0; i < chunk.count; i++ {
fn(&chunk.items[i])
}
}
if done != nil {
done()
}
cl.mutex.Unlock()
}
// Snapshot returns immutable snapshot of the ChunkList // Snapshot returns immutable snapshot of the ChunkList
func (cl *ChunkList) Snapshot(tail int) ([]*Chunk, int, bool) { func (cl *ChunkList) Snapshot(tail int) ([]*Chunk, int, bool) {
cl.mutex.Lock() cl.mutex.Lock()
+6 -4
View File
@@ -34,18 +34,19 @@ const (
maxBgProcessesPerAction = 3 maxBgProcessesPerAction = 3
// Matcher // Matcher
progressMinDuration = 200 * time.Millisecond numPartitionsMultiplier = 8
maxPartitions = 32
progressMinDuration = 200 * time.Millisecond
// Capacity of each chunk // Capacity of each chunk
chunkSize int = 1024 chunkSize int = 100
chunkBitWords = (chunkSize + 63) / 64
// Pre-allocated memory slices to minimize GC // Pre-allocated memory slices to minimize GC
slab16Size int = 100 * 1024 // 200KB * 32 = 12.8MB slab16Size int = 100 * 1024 // 200KB * 32 = 12.8MB
slab32Size int = 2048 // 8KB * 32 = 256KB slab32Size int = 2048 // 8KB * 32 = 256KB
// Do not cache results of low selectivity queries // Do not cache results of low selectivity queries
queryCacheMax int = chunkSize / 2 queryCacheMax int = chunkSize / 5
// Not to cache mergers with large lists // Not to cache mergers with large lists
mergerCacheMax int = 100000 mergerCacheMax int = 100000
@@ -64,6 +65,7 @@ const (
EvtSearchNew EvtSearchNew
EvtSearchProgress EvtSearchProgress
EvtSearchFin EvtSearchFin
EvtHeader
EvtReady EvtReady
EvtQuit EvtQuit
) )
+68 -178
View File
@@ -2,9 +2,7 @@
package fzf package fzf
import ( import (
"fmt"
"maps" "maps"
"math"
"os" "os"
"sync" "sync"
"time" "time"
@@ -19,6 +17,7 @@ Reader -> EvtReadNew -> Matcher (restart)
Terminal -> EvtSearchNew:bool -> Matcher (restart) Terminal -> EvtSearchNew:bool -> Matcher (restart)
Matcher -> EvtSearchProgress -> Terminal (update info) Matcher -> EvtSearchProgress -> Terminal (update info)
Matcher -> EvtSearchFin -> Terminal (update list) Matcher -> EvtSearchFin -> Terminal (update list)
Matcher -> EvtHeader -> Terminal (update header)
*/ */
type revision struct { type revision struct {
@@ -39,27 +38,12 @@ func (r revision) compatible(other revision) bool {
return r.major == other.major 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 // Run starts fzf
func Run(opts *Options) (int, error) { func Run(opts *Options) (int, error) {
if opts.Filter == nil { if opts.Filter == nil {
if opts.useTmux() { if opts.useTmux() {
return runTmux(os.Args, opts) return runTmux(os.Args, opts)
} }
if opts.useZellij() {
return runZellij(os.Args, opts)
}
if needWinpty(opts) { if needWinpty(opts) {
return runWinpty(os.Args, opts) return runWinpty(os.Args, opts)
@@ -117,57 +101,57 @@ func Run(opts *Options) (int, error) {
cache := NewChunkCache() cache := NewChunkCache()
var chunkList *ChunkList var chunkList *ChunkList
var itemIndex int32 var itemIndex int32
// transformItem applies with-nth transformation to an item's raw data. header := make([]string, 0, opts.HeaderLines)
// 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 { if opts.WithNth == nil {
chunkList = NewChunkList(cache, func(item *Item, data []byte) bool { 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, item.colors = ansiProcessor(data)
item.text.Index = itemIndex item.text.Index = itemIndex
itemIndex++ itemIndex++
return true return true
}) })
} else { } else {
nthTransformer = opts.WithNth(opts.Delimiter) nthTransformer := opts.WithNth(opts.Delimiter)
chunkList = NewChunkList(cache, func(item *Item, data []byte) bool { chunkList = NewChunkList(cache, func(item *Item, data []byte) bool {
if nthTransformer == nil { tokens := Tokenize(byteString(data), opts.Delimiter)
item.text, item.colors = ansiProcessor(data) if opts.Ansi && len(tokens) > 1 {
} else { var ansiState *ansiState
transformItem(item, data, nthTransformer, itemIndex) 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 := 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.text.Index = itemIndex
item.origText = &data item.origText = &data
itemIndex++ itemIndex++
@@ -197,15 +181,13 @@ func Run(opts *Options) (int, error) {
} }
// Reader // Reader
streamingFilter := opts.Filter != nil && !sort && !opts.Tac && !opts.Sync && opts.Bench == 0 streamingFilter := opts.Filter != nil && !sort && !opts.Tac && !opts.Sync
var reader *Reader var reader *Reader
var ingestionStart time.Time
if !streamingFilter { if !streamingFilter {
reader = NewReader(func(data []byte) bool { reader = NewReader(func(data []byte) bool {
return chunkList.Push(data) return chunkList.Push(data)
}, eventBox, executor, opts.ReadZero, opts.Filter == nil) }, eventBox, executor, opts.ReadZero, opts.Filter == nil)
ingestionStart = time.Now()
readyChan := make(chan bool) readyChan := make(chan bool)
go reader.ReadSource(opts.Input, opts.WalkerRoot, opts.WalkerOpts, opts.WalkerSkip, initialReload, initialEnv, readyChan) go reader.ReadSource(opts.Input, opts.WalkerRoot, opts.WalkerOpts, opts.WalkerSkip, initialReload, initialEnv, readyChan)
<-readyChan <-readyChan
@@ -242,20 +224,15 @@ func Run(opts *Options) (int, error) {
denylist = make(map[int32]struct{}) denylist = make(map[int32]struct{})
denyMutex.Unlock() denyMutex.Unlock()
} }
if opts.HeaderLines > math.MaxInt32 {
opts.HeaderLines = math.MaxInt32
}
headerLines := int32(opts.HeaderLines)
headerUpdated := false
patternBuilder := func(runes []rune) *Pattern { patternBuilder := func(runes []rune) *Pattern {
denyMutex.Lock() denyMutex.Lock()
denylistCopy := maps.Clone(denylist) denylistCopy := maps.Clone(denylist)
denyMutex.Unlock() denyMutex.Unlock()
return BuildPattern(cache, patternCache, return BuildPattern(cache, patternCache,
opts.Fuzzy, opts.FuzzyAlgo, opts.Extended, opts.Case, opts.Normalize, forward, withPos, opts.Fuzzy, opts.FuzzyAlgo, opts.Extended, opts.Case, opts.Normalize, forward, withPos,
opts.Filter == nil, nth, opts.Delimiter, inputRevision, runes, denylistCopy, headerLines) opts.Filter == nil, nth, opts.Delimiter, inputRevision, runes, denylistCopy)
} }
matcher := NewMatcher(cache, patternBuilder, sort, opts.Tac, eventBox, inputRevision, opts.Threads) matcher := NewMatcher(cache, patternBuilder, sort, opts.Tac, eventBox, inputRevision)
// Filtering mode // Filtering mode
if opts.Filter != nil { if opts.Filter != nil {
@@ -266,8 +243,6 @@ func Run(opts *Options) (int, error) {
pattern := patternBuilder([]rune(*opts.Filter)) pattern := patternBuilder([]rune(*opts.Filter))
matcher.sort = pattern.sortable matcher.sort = pattern.sortable
transformer := buildItemTransformer(opts)
found := false found := false
if streamingFilter { if streamingFilter {
slab := util.MakeSlab(slab16Size, slab32Size) slab := util.MakeSlab(slab16Size, slab32Size)
@@ -276,12 +251,9 @@ func Run(opts *Options) (int, error) {
func(runes []byte) bool { func(runes []byte) bool {
item := Item{} item := Item{}
if chunkList.trans(&item, runes) { if chunkList.trans(&item, runes) {
if item.Index() < headerLines {
return false
}
mutex.Lock() mutex.Lock()
if result, _, _ := pattern.MatchItem(&item, false, slab); result.item != nil { if result, _, _ := pattern.MatchItem(&item, false, slab); result != nil {
opts.Printer(transformer(&item)) opts.Printer(item.text.ToString())
found = true found = true
} }
mutex.Unlock() mutex.Unlock()
@@ -292,56 +264,14 @@ func Run(opts *Options) (int, error) {
} else { } else {
eventBox.Unwatch(EvtReadNew) eventBox.Unwatch(EvtReadNew)
eventBox.WaitFor(EvtReadFin) eventBox.WaitFor(EvtReadFin)
ingestionTime := time.Since(ingestionStart)
// NOTE: Streaming filter is inherently not compatible with --tail // NOTE: Streaming filter is inherently not compatible with --tail
snapshot, _, _ := chunkList.Snapshot(opts.Tail) snapshot, _, _ := chunkList.Snapshot(opts.Tail)
if opts.Bench > 0 {
// Benchmark mode: repeat scan for the given duration
totalItems := CountItems(snapshot)
var matchCount int
var times []time.Duration
deadline := time.Now().Add(opts.Bench)
for time.Now().Before(deadline) {
cache.Clear()
start := time.Now()
result := matcher.scan(MatchRequest{
chunks: snapshot,
pattern: pattern})
times = append(times, time.Since(start))
matchCount = result.merger.Length()
}
// Print stats
var total time.Duration
minD, maxD := times[0], times[0]
for _, d := range times {
total += d
if d < minD {
minD = d
}
if d > maxD {
maxD = d
}
}
avg := total / time.Duration(len(times))
selectivity := float64(matchCount) / float64(totalItems) * 100
fmt.Printf(" %d iterations avg: %.2fms min: %.2fms max: %.2fms total: %.2fs items: %d matches: %d (%.2f%%) ingestion: %.2fms\n",
len(times),
float64(avg.Microseconds())/1000,
float64(minD.Microseconds())/1000,
float64(maxD.Microseconds())/1000,
total.Seconds(),
totalItems, matchCount, selectivity,
float64(ingestionTime.Microseconds())/1000)
return ExitOk, nil
}
result := matcher.scan(MatchRequest{ result := matcher.scan(MatchRequest{
chunks: snapshot, chunks: snapshot,
pattern: pattern}) pattern: pattern})
for i := 0; i < result.merger.Length(); i++ { for i := 0; i < result.merger.Length(); i++ {
opts.Printer(transformer(result.merger.Get(i).item)) opts.Printer(result.merger.Get(i).item.AsString(opts.Ansi))
found = true found = true
} }
} }
@@ -386,11 +316,10 @@ func Run(opts *Options) (int, error) {
query := []rune{} query := []rune{}
determine := func(final bool) { determine := func(final bool) {
if heightUnknown { if heightUnknown {
items := max(0, total-int(headerLines)) if total >= maxFit || final {
if items >= maxFit || final {
deferred = false deferred = false
heightUnknown = false heightUnknown = false
terminal.startChan <- fitpad{min(items, maxFit), padHeight} terminal.startChan <- fitpad{util.Min(total, maxFit), padHeight}
} }
} else if deferred { } else if deferred {
deferred = false deferred = false
@@ -406,11 +335,11 @@ func Run(opts *Options) (int, error) {
clearDenylist() clearDenylist()
} }
reading = true reading = true
headerUpdated = false
startTick = ticks startTick = ticks
chunkList.Clear() chunkList.Clear()
itemIndex = 0 itemIndex = 0
inputRevision.bumpMajor() inputRevision.bumpMajor()
header = make([]string, 0, opts.HeaderLines)
readyChan := make(chan bool) readyChan := make(chan bool)
go reader.restart(command, environ, readyChan) go reader.restart(command, environ, readyChan)
<-readyChan <-readyChan
@@ -468,24 +397,16 @@ func Run(opts *Options) (int, error) {
snapshotRevision = inputRevision snapshotRevision = inputRevision
} }
total = count total = count
terminal.UpdateCount(max(0, total-int(headerLines)), !reading, value.(*string)) terminal.UpdateCount(total, !reading, value.(*string))
if headerLines > 0 && !headerUpdated {
terminal.UpdateHeader(GetItems(snapshot, int(headerLines)))
headerUpdated = total >= int(headerLines)
}
if heightUnknown && !deferred { if heightUnknown && !deferred {
determine(!reading) determine(!reading)
} }
if !useSnapshot || evt == EvtReadFin { matcher.Reset(snapshot, input(), false, !reading, sort, snapshotRevision)
matcher.Reset(snapshot, input(), false, !reading, sort, snapshotRevision)
}
case EvtSearchNew: case EvtSearchNew:
var command *commandSpec var command *commandSpec
var environ []string var environ []string
var changed bool var changed bool
headerLinesChanged := false
withNthChanged := false
switch val := value.(type) { switch val := value.(type) {
case searchRequest: case searchRequest:
sort = val.sort sort = val.sort
@@ -506,40 +427,6 @@ func Run(opts *Options) (int, error) {
nth = *val.nth nth = *val.nth
bump = true bump = true
} }
if val.headerLines != nil {
headerLines = int32(*val.headerLines)
headerUpdated = false
headerLinesChanged = true
bump = true
}
if val.withNth != nil {
newTransformer := val.withNth.fn
// Cancel any in-flight scan and block the terminal from reading
// items before mutating them in-place. Snapshot shares middle
// chunk pointers, so the matcher and terminal can race with us.
matcher.CancelScan()
terminal.PauseRendering()
// Reset cross-line ANSI state before re-processing all items
lineAnsiState = nil
prevLineAnsiState = nil
chunkList.ForEachItem(func(item *Item) {
origBytes := *item.origText
savedIndex := item.Index()
if newTransformer != nil {
transformItem(item, origBytes, newTransformer, savedIndex)
} else {
item.text, item.colors = ansiProcessor(origBytes)
}
item.text.Index = savedIndex
item.transformed = nil
}, func() {
nthTransformer = newTransformer
})
terminal.ResumeRendering()
matcher.ResumeScan()
withNthChanged = true
bump = true
}
if bump { if bump {
patternCache = make(map[string]*Pattern) patternCache = make(map[string]*Pattern)
cache.Clear() cache.Clear()
@@ -576,16 +463,6 @@ func Run(opts *Options) (int, error) {
snapshotRevision = inputRevision snapshotRevision = inputRevision
} }
} }
if headerLinesChanged {
terminal.UpdateCount(max(0, total-int(headerLines)), !reading, nil)
if headerLines > 0 {
terminal.UpdateHeader(GetItems(snapshot, int(headerLines)))
} else {
terminal.UpdateHeader(nil)
}
} else if withNthChanged && headerLines > 0 {
terminal.UpdateHeader(GetItems(snapshot, int(headerLines)))
}
matcher.Reset(snapshot, input(), true, !reading, sort, snapshotRevision) matcher.Reset(snapshot, input(), true, !reading, sort, snapshotRevision)
delay = false delay = false
@@ -595,6 +472,11 @@ func Run(opts *Options) (int, error) {
terminal.UpdateProgress(val) terminal.UpdateProgress(val)
} }
case EvtHeader:
headerPadded := make([]string, opts.HeaderLines)
copy(headerPadded, value.([]string))
terminal.UpdateHeader(headerPadded)
case EvtSearchFin: case EvtSearchFin:
switch val := value.(type) { switch val := value.(type) {
case MatchResult: case MatchResult:
@@ -611,7 +493,15 @@ func Run(opts *Options) (int, error) {
if len(opts.Expect) > 0 { if len(opts.Expect) > 0 {
opts.Printer("") opts.Printer("")
} }
transformer := buildItemTransformer(opts) 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)
}
}
for i := range count { for i := range count {
opts.Printer(transformer(merger.Get(i).item)) opts.Printer(transformer(merger.Get(i).item))
} }
@@ -634,7 +524,7 @@ func Run(opts *Options) (int, error) {
break break
} }
if delay && reading { if delay && reading {
dur := util.Constrain( dur := util.DurWithin(
time.Duration(ticks-startTick)*coordinatorDelayStep, time.Duration(ticks-startTick)*coordinatorDelayStep,
0, coordinatorDelayMax) 0, coordinatorDelayMax)
time.Sleep(dur) time.Sleep(dur)
+53 -50
View File
@@ -3,8 +3,8 @@ package fzf
import ( import (
"fmt" "fmt"
"runtime" "runtime"
"sort"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/junegunn/fzf/src/util" "github.com/junegunn/fzf/src/util"
@@ -43,11 +43,8 @@ type Matcher struct {
reqBox *util.EventBox reqBox *util.EventBox
partitions int partitions int
slab []*util.Slab slab []*util.Slab
sortBuf [][]Result
mergerCache map[string]MatchResult mergerCache map[string]MatchResult
revision revision revision revision
scanMutex sync.Mutex
cancelScan *util.AtomicBool
} }
const ( const (
@@ -57,11 +54,8 @@ const (
// NewMatcher returns a new Matcher // NewMatcher returns a new Matcher
func NewMatcher(cache *ChunkCache, patternBuilder func([]rune) *Pattern, func NewMatcher(cache *ChunkCache, patternBuilder func([]rune) *Pattern,
sort bool, tac bool, eventBox *util.EventBox, revision revision, threads int) *Matcher { sort bool, tac bool, eventBox *util.EventBox, revision revision) *Matcher {
partitions := runtime.NumCPU() partitions := util.Min(numPartitionsMultiplier*runtime.NumCPU(), maxPartitions)
if threads > 0 {
partitions = threads
}
return &Matcher{ return &Matcher{
cache: cache, cache: cache,
patternBuilder: patternBuilder, patternBuilder: patternBuilder,
@@ -71,10 +65,8 @@ func NewMatcher(cache *ChunkCache, patternBuilder func([]rune) *Pattern,
reqBox: util.NewEventBox(), reqBox: util.NewEventBox(),
partitions: partitions, partitions: partitions,
slab: make([]*util.Slab, partitions), slab: make([]*util.Slab, partitions),
sortBuf: make([][]Result, partitions),
mergerCache: make(map[string]MatchResult), mergerCache: make(map[string]MatchResult),
revision: revision, revision: revision}
cancelScan: util.NewAtomicBool(false)}
} }
// Loop puts Matcher in action // Loop puts Matcher in action
@@ -134,9 +126,7 @@ func (m *Matcher) Loop() {
} }
if result.merger == nil { if result.merger == nil {
m.scanMutex.Lock()
result = m.scan(request) result = m.scan(request)
m.scanMutex.Unlock()
} }
if !result.cancelled { if !result.cancelled {
@@ -149,6 +139,27 @@ func (m *Matcher) Loop() {
} }
} }
func (m *Matcher) sliceChunks(chunks []*Chunk) [][]*Chunk {
partitions := m.partitions
perSlice := len(chunks) / partitions
if perSlice == 0 {
partitions = len(chunks)
perSlice = 1
}
slices := make([][]*Chunk, partitions)
for i := 0; i < partitions; i++ {
start := i * perSlice
end := start + perSlice
if i == partitions-1 {
end = len(chunks)
}
slices[i] = chunks[start:end]
}
return slices
}
type partialResult struct { type partialResult struct {
index int index int
matches []Result matches []Result
@@ -163,7 +174,7 @@ func (m *Matcher) scan(request MatchRequest) MatchResult {
return MatchResult{m, m, false} return MatchResult{m, m, false}
} }
pattern := request.pattern pattern := request.pattern
passMerger := PassMerger(&request.chunks, m.tac, request.revision, pattern.startIndex) passMerger := PassMerger(&request.chunks, m.tac, request.revision)
if pattern.IsEmpty() { if pattern.IsEmpty() {
return MatchResult{passMerger, passMerger, false} return MatchResult{passMerger, passMerger, false}
} }
@@ -172,37 +183,43 @@ func (m *Matcher) scan(request MatchRequest) MatchResult {
maxIndex := request.chunks[numChunks-1].lastIndex(minIndex) maxIndex := request.chunks[numChunks-1].lastIndex(minIndex)
cancelled := util.NewAtomicBool(false) cancelled := util.NewAtomicBool(false)
numWorkers := min(m.partitions, numChunks) slices := m.sliceChunks(request.chunks)
var nextChunk atomic.Int32 numSlices := len(slices)
resultChan := make(chan partialResult, numWorkers) resultChan := make(chan partialResult, numSlices)
countChan := make(chan int, numChunks) countChan := make(chan int, numChunks)
waitGroup := sync.WaitGroup{} waitGroup := sync.WaitGroup{}
for idx := range numWorkers { for idx, chunks := range slices {
waitGroup.Add(1) waitGroup.Add(1)
if m.slab[idx] == nil { if m.slab[idx] == nil {
m.slab[idx] = util.MakeSlab(slab16Size, slab32Size) m.slab[idx] = util.MakeSlab(slab16Size, slab32Size)
} }
go func(idx int, slab *util.Slab) { go func(idx int, slab *util.Slab, chunks []*Chunk) {
defer waitGroup.Done() defer func() { waitGroup.Done() }()
var matches []Result count := 0
for { allMatches := make([][]Result, len(chunks))
ci := int(nextChunk.Add(1)) - 1 for idx, chunk := range chunks {
if ci >= numChunks { matches := request.pattern.Match(chunk, slab)
break allMatches[idx] = matches
} count += len(matches)
chunkMatches := request.pattern.Match(request.chunks[ci], slab)
matches = append(matches, chunkMatches...)
if cancelled.Get() { if cancelled.Get() {
return return
} }
countChan <- len(chunkMatches) countChan <- len(matches)
}
sliceMatches := make([]Result, 0, count)
for _, matches := range allMatches {
sliceMatches = append(sliceMatches, matches...)
} }
if m.sort && request.pattern.sortable { if m.sort && request.pattern.sortable {
m.sortBuf[idx] = radixSortResults(matches, m.tac, m.sortBuf[idx]) if m.tac {
sort.Sort(ByRelevanceTac(sliceMatches))
} else {
sort.Sort(ByRelevance(sliceMatches))
}
} }
resultChan <- partialResult{idx, matches} resultChan <- partialResult{idx, sliceMatches}
}(idx, m.slab[idx]) }(idx, m.slab[idx], chunks)
} }
wait := func() bool { wait := func() bool {
@@ -221,7 +238,7 @@ func (m *Matcher) scan(request MatchRequest) MatchResult {
break break
} }
if m.cancelScan.Get() || m.reqBox.Peek(reqReset) { if m.reqBox.Peek(reqReset) {
return MatchResult{nil, nil, wait()} return MatchResult{nil, nil, wait()}
} }
@@ -230,8 +247,8 @@ func (m *Matcher) scan(request MatchRequest) MatchResult {
} }
} }
partialResults := make([][]Result, numWorkers) partialResults := make([][]Result, numSlices)
for range numWorkers { for range slices {
partialResult := <-resultChan partialResult := <-resultChan
partialResults[partialResult.index] = partialResult.matches partialResults[partialResult.index] = partialResult.matches
} }
@@ -252,20 +269,6 @@ func (m *Matcher) Reset(chunks []*Chunk, patternRunes []rune, cancel bool, final
m.reqBox.Set(event, MatchRequest{chunks, pattern, final, sort, revision}) m.reqBox.Set(event, MatchRequest{chunks, pattern, final, sort, revision})
} }
// CancelScan cancels any in-flight scan, waits for it to finish,
// and prevents new scans from starting until ResumeScan is called.
// This is used to safely mutate shared items (e.g., during with-nth changes).
func (m *Matcher) CancelScan() {
m.cancelScan.Set(true)
m.scanMutex.Lock()
m.cancelScan.Set(false)
}
// ResumeScan allows scans to proceed again after CancelScan.
func (m *Matcher) ResumeScan() {
m.scanMutex.Unlock()
}
func (m *Matcher) Stop() { func (m *Matcher) Stop() {
m.reqBox.Set(reqQuit, nil) m.reqBox.Set(reqQuit, nil)
} }
+32 -30
View File
@@ -10,46 +10,42 @@ func EmptyMerger(revision revision) *Merger {
// Merger holds a set of locally sorted lists of items and provides the view of // Merger holds a set of locally sorted lists of items and provides the view of
// a single, globally-sorted list // a single, globally-sorted list
type Merger struct { type Merger struct {
pattern *Pattern pattern *Pattern
lists [][]Result lists [][]Result
merged []Result merged []Result
chunks *[]*Chunk chunks *[]*Chunk
cursors []int cursors []int
sorted bool sorted bool
tac bool tac bool
final bool final bool
count int count int
pass bool pass bool
startIndex int revision revision
revision revision minIndex int32
minIndex int32 maxIndex int32
maxIndex int32
} }
// PassMerger returns a new Merger that simply returns the items in the // PassMerger returns a new Merger that simply returns the items in the
// original order. startIndex items are skipped from the beginning. // original order
func PassMerger(chunks *[]*Chunk, tac bool, revision revision, startIndex int32) *Merger { func PassMerger(chunks *[]*Chunk, tac bool, revision revision) *Merger {
var minIndex, maxIndex int32 var minIndex, maxIndex int32
if len(*chunks) > 0 { if len(*chunks) > 0 {
minIndex = (*chunks)[0].items[0].Index() minIndex = (*chunks)[0].items[0].Index()
maxIndex = (*chunks)[len(*chunks)-1].lastIndex(minIndex) maxIndex = (*chunks)[len(*chunks)-1].lastIndex(minIndex)
} }
si := int(startIndex)
mg := Merger{ mg := Merger{
pattern: nil, pattern: nil,
chunks: chunks, chunks: chunks,
tac: tac, tac: tac,
count: 0, count: 0,
pass: true, pass: true,
startIndex: si, revision: revision,
revision: revision, minIndex: minIndex,
minIndex: minIndex + startIndex, maxIndex: maxIndex}
maxIndex: maxIndex}
for _, chunk := range *mg.chunks { for _, chunk := range *mg.chunks {
mg.count += chunk.count mg.count += chunk.count
} }
mg.count = max(0, mg.count-si)
return &mg return &mg
} }
@@ -117,7 +113,6 @@ func (mg *Merger) Get(idx int) Result {
if mg.tac { if mg.tac {
idx = mg.count - idx - 1 idx = mg.count - idx - 1
} }
idx += mg.startIndex
firstChunk := (*mg.chunks)[0] firstChunk := (*mg.chunks)[0]
if firstChunk.count < chunkSize && idx >= firstChunk.count { if firstChunk.count < chunkSize && idx >= firstChunk.count {
idx -= firstChunk.count idx -= firstChunk.count
@@ -136,7 +131,14 @@ func (mg *Merger) Get(idx int) Result {
if mg.tac { if mg.tac {
idx = mg.count - idx - 1 idx = mg.count - idx - 1
} }
return mg.mergedGet(idx) 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))
} }
func (mg *Merger) ToMap() map[int32]Result { func (mg *Merger) ToMap() map[int32]Result {
@@ -164,7 +166,7 @@ func (mg *Merger) mergedGet(idx int) Result {
} }
if cursor >= 0 { if cursor >= 0 {
rank := list[cursor] rank := list[cursor]
if minIdx < 0 || mg.sorted && compareRanks(rank, minRank, mg.tac) || !mg.sorted && rank.item.Index() < minRank.item.Index() { if minIdx < 0 || compareRanks(rank, minRank, mg.tac) {
minRank = rank minRank = rank
minIdx = listIdx minIdx = listIdx
} }
+2 -17
View File
@@ -54,25 +54,10 @@ func buildLists(partiallySorted bool) ([][]Result, []Result) {
} }
func TestMergerUnsorted(t *testing.T) { func TestMergerUnsorted(t *testing.T) {
lists, _ := buildLists(false) lists, items := buildLists(false)
// Sort each list by index to simulate real worker behavior
// (workers process chunks in ascending order via nextChunk.Add(1))
for _, list := range lists {
sort.Slice(list, func(i, j int) bool {
return list[i].item.Index() < list[j].item.Index()
})
}
items := []Result{}
for _, list := range lists {
items = append(items, list...)
}
sort.Slice(items, func(i, j int) bool {
return items[i].item.Index() < items[j].item.Index()
})
cnt := len(items) cnt := len(items)
// Not sorted: items in ascending index order // Not sorted: same order
mg := NewMerger(nil, lists, false, false, revision{}, 0, 0) mg := NewMerger(nil, lists, false, false, revision{}, 0, 0)
assert(t, cnt == mg.Length(), "Invalid Length") assert(t, cnt == mg.Length(), "Invalid Length")
for i := range cnt { for i := range cnt {
+49 -239
View File
@@ -4,12 +4,11 @@ import (
"errors" "errors"
"fmt" "fmt"
"maps" "maps"
"math"
"os" "os"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"time" "unicode"
"github.com/junegunn/fzf/src/algo" "github.com/junegunn/fzf/src/algo"
"github.com/junegunn/fzf/src/tui" "github.com/junegunn/fzf/src/tui"
@@ -66,7 +65,7 @@ Usage: fzf [options]
--no-bold Do not use bold text --no-bold Do not use bold text
DISPLAY MODE DISPLAY MODE
--height=[~][-]HEIGHT[%] Display fzf window below the cursor with the given --height=[~]HEIGHT[%] Display fzf window below the cursor with the given
height instead of using fullscreen. height instead of using fullscreen.
A negative value is calculated as the terminal height A negative value is calculated as the terminal height
minus the given value. minus the given value.
@@ -75,17 +74,16 @@ Usage: fzf [options]
--min-height=HEIGHT[+] Minimum height when --height is given as a percentage. --min-height=HEIGHT[+] Minimum height when --height is given as a percentage.
Add '+' to automatically increase the value Add '+' to automatically increase the value
according to the other layout options (default: 10+). according to the other layout options (default: 10+).
--popup[=OPTS] Start fzf in a floating pane (requires tmux 3.3+ or Zellij 0.44+) --tmux[=OPTS] Start fzf in a tmux popup (requires tmux 3.3+)
[center|top|bottom|left|right][,SIZE[%]][,SIZE[%]] [center|top|bottom|left|right][,SIZE[%]][,SIZE[%]]
[,border-native] (default: center,50%) [,border-native] (default: center,50%)
--tmux[=OPTS] Alias for --popup
LAYOUT LAYOUT
--layout=LAYOUT Choose layout: [default|reverse|reverse-list] --layout=LAYOUT Choose layout: [default|reverse|reverse-list]
--margin=MARGIN Screen margin (TRBL | TB,RL | T,RL,B | T,R,B,L) --margin=MARGIN Screen margin (TRBL | TB,RL | T,RL,B | T,R,B,L)
--padding=PADDING Padding inside border (TRBL | TB,RL | T,RL,B | T,R,B,L) --padding=PADDING Padding inside border (TRBL | TB,RL | T,RL,B | T,R,B,L)
--border[=STYLE] Draw border around the finder --border[=STYLE] Draw border around the finder
[rounded|sharp|bold|block|thinblock|double|dashed|horizontal|vertical| [rounded|sharp|bold|block|thinblock|double|horizontal|vertical|
top|bottom|left|right|line|none] (default: rounded) top|bottom|left|right|line|none] (default: rounded)
--border-label=LABEL Label to print on the border --border-label=LABEL Label to print on the border
--border-label-pos=COL Position of the border label --border-label-pos=COL Position of the border label
@@ -97,12 +95,11 @@ Usage: fzf [options]
-m, --multi[=MAX] Enable multi-select with tab/shift-tab -m, --multi[=MAX] Enable multi-select with tab/shift-tab
--highlight-line Highlight the whole current line --highlight-line Highlight the whole current line
--cycle Enable cyclic scroll --cycle Enable cyclic scroll
--wrap[=MODE] Enable line wrap (char|word, default: char) --wrap Enable line wrap
--wrap-sign=STR Indicator for wrapped lines --wrap-sign=STR Indicator for wrapped lines
--no-multi-line Disable multi-line display of items when using --read0 --no-multi-line Disable multi-line display of items when using --read0
--raw Enable raw mode (show non-matching items) --raw Enable raw mode (show non-matching items)
--track Track the current selection when the result is updated --track Track the current selection when the result is updated
--id-nth=N[,..] Define item identity fields for cross-reload operations
--tac Reverse the order of the input --tac Reverse the order of the input
--gap[=N] Render empty lines between each item --gap[=N] Render empty lines between each item
--gap-line[=STR] Draw horizontal line on each gap using the string --gap-line[=STR] Draw horizontal line on each gap using the string
@@ -128,7 +125,7 @@ Usage: fzf [options]
(each for list section and preview window) (each for list section and preview window)
--no-scrollbar Hide scrollbar --no-scrollbar Hide scrollbar
--list-border[=STYLE] Draw border around the list section --list-border[=STYLE] Draw border around the list section
[rounded|sharp|bold|block|thinblock|double|dashed|horizontal|vertical| [rounded|sharp|bold|block|thinblock|double|horizontal|vertical|
top|bottom|left|right|none] (default: rounded) top|bottom|left|right|none] (default: rounded)
--list-label=LABEL Label to print on the list border --list-label=LABEL Label to print on the list border
--list-label-pos=COL Position of the list label --list-label-pos=COL Position of the list label
@@ -148,7 +145,7 @@ Usage: fzf [options]
--ghost=TEXT Ghost text to display when the input is empty --ghost=TEXT Ghost text to display when the input is empty
--filepath-word Make word-wise movements respect path separators --filepath-word Make word-wise movements respect path separators
--input-border[=STYLE] Draw border around the input section --input-border[=STYLE] Draw border around the input section
[rounded|sharp|bold|block|thinblock|double|dashed|horizontal|vertical| [rounded|sharp|bold|block|thinblock|double|horizontal|vertical|
top|bottom|left|right|line|none] (default: rounded) top|bottom|left|right|line|none] (default: rounded)
--input-label=LABEL Label to print on the input border --input-label=LABEL Label to print on the input border
--input-label-pos=COL Position of the input label --input-label-pos=COL Position of the input label
@@ -159,30 +156,28 @@ Usage: fzf [options]
PREVIEW WINDOW PREVIEW WINDOW
--preview=COMMAND Command to preview highlighted line ({}) --preview=COMMAND Command to preview highlighted line ({})
--preview-window=OPT Preview window layout (default: right:50%) --preview-window=OPT Preview window layout (default: right:50%)
[up|down|left|right|next][,SIZE[%]] [up|down|left|right][,SIZE[%]]
[,[no]wrap[-word]][,[no]cycle][,[no]follow][,[no]info] [,[no]wrap][,[no]cycle][,[no]follow][,[no]info]
[,[no]hidden][,border-STYLE] [,[no]hidden][,border-STYLE]
[,+SCROLL[OFFSETS][/DENOM]][,~HEADER_LINES] [,+SCROLL[OFFSETS][/DENOM]][,~HEADER_LINES]
[,default][,<SIZE_THRESHOLD(ALTERNATIVE_LAYOUT)] [,default][,<SIZE_THRESHOLD(ALTERNATIVE_LAYOUT)]
--preview-border[=STYLE] Short for --preview-window=border-STYLE --preview-border[=STYLE] Short for --preview-window=border-STYLE
[rounded|sharp|bold|block|thinblock|double|dashed|horizontal|vertical| [rounded|sharp|bold|block|thinblock|double|horizontal|vertical|
top|bottom|left|right|line|none] (default: rounded) top|bottom|left|right|line|none] (default: rounded)
--preview-label=LABEL --preview-label=LABEL
--preview-label-pos=N Same as --border-label and --border-label-pos, --preview-label-pos=N Same as --border-label and --border-label-pos,
but for preview window but for preview window
--preview-wrap-sign=STR Indicator for wrapped lines in the preview window
HEADER HEADER
--header=STR String to print as header --header=STR String to print as header
--header-lines=N The first N lines of the input are treated as header --header-lines=N The first N lines of the input are treated as header
--header-first Print header before the prompt line --header-first Print header before the prompt line
--header-border[=STYLE] Draw border around the header section --header-border[=STYLE] Draw border around the header section
[rounded|sharp|bold|block|thinblock|double|dashed|horizontal|vertical| [rounded|sharp|bold|block|thinblock|double|horizontal|vertical|
top|bottom|left|right|line|inline|none] (default: rounded) top|bottom|left|right|line|none] (default: rounded)
--header-lines-border[=STYLE] --header-lines-border[=STYLE]
Display header from --header-lines with a separate border. Display header from --header-lines with a separate border.
Pass 'none' to still separate it but without a border. Pass 'none' to still separate it but without a border.
Pass 'inline' to embed it inside the list frame.
--header-label=LABEL Label to print on the header border --header-label=LABEL Label to print on the header border
--header-label-pos=COL Position of the header label --header-label-pos=COL Position of the header label
[POSITIVE_INTEGER: columns from left| [POSITIVE_INTEGER: columns from left|
@@ -192,8 +187,8 @@ Usage: fzf [options]
FOOTER FOOTER
--footer=STR String to print as footer --footer=STR String to print as footer
--footer-border[=STYLE] Draw border around the footer section --footer-border[=STYLE] Draw border around the footer section
[rounded|sharp|bold|block|thinblock|double|dashed|horizontal|vertical| [rounded|sharp|bold|block|thinblock|double|horizontal|vertical|
top|bottom|left|right|line|inline|none] (default: line) top|bottom|left|right|line|none] (default: line)
--footer-label=LABEL Label to print on the footer border --footer-label=LABEL Label to print on the footer border
--footer-label-pos=COL Position of the footer label --footer-label-pos=COL Position of the footer label
[POSITIVE_INTEGER: columns from left| [POSITIVE_INTEGER: columns from left|
@@ -232,7 +227,6 @@ Usage: fzf [options]
--bash Print script to set up Bash shell integration --bash Print script to set up Bash shell integration
--zsh Print script to set up Zsh shell integration --zsh Print script to set up Zsh shell integration
--fish Print script to set up Fish shell integration --fish Print script to set up Fish shell integration
--nushell Print script to set up Nushell integration
HELP HELP
--version Display version information and exit --version Display version information and exit
@@ -298,32 +292,14 @@ func defaultMargin() [4]sizeSpec {
return [4]sizeSpec{} return [4]sizeSpec{}
} }
type trackOption struct { type trackOption int
enabled bool
index int32
}
var ( const (
trackDisabled = trackOption{false, minItem.Index()} trackDisabled trackOption = iota
trackEnabled = trackOption{true, minItem.Index()} trackEnabled
trackCurrent
) )
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 type windowPosition int
const ( const (
@@ -332,7 +308,6 @@ const (
posLeft posLeft
posRight posRight
posCenter posCenter
posNext // adjacent to the input section, on the list side
) )
type tmuxOptions struct { type tmuxOptions struct {
@@ -374,7 +349,6 @@ type previewOpts struct {
scroll string scroll string
hidden bool hidden bool
wrap bool wrap bool
wrapWord bool
cycle bool cycle bool
follow bool follow bool
info bool info bool
@@ -392,7 +366,7 @@ func (o *previewOpts) Toggle() {
o.hidden = !o.hidden o.hidden = !o.hidden
} }
func (o *previewOpts) Border(layout layoutType) tui.BorderShape { func (o *previewOpts) Border() tui.BorderShape {
shape := o.border shape := o.border
if shape == tui.BorderLine { if shape == tui.BorderLine {
switch o.position { switch o.position {
@@ -404,12 +378,6 @@ func (o *previewOpts) Border(layout layoutType) tui.BorderShape {
shape = tui.BorderRight shape = tui.BorderRight
case posRight: case posRight:
shape = tui.BorderLeft shape = tui.BorderLeft
case posNext:
if layout == layoutReverse {
shape = tui.BorderBottom
} else {
shape = tui.BorderTop
}
} }
} }
return shape return shape
@@ -427,7 +395,7 @@ func parseTmuxOptions(arg string, index int) (*tmuxOptions, error) {
var err error var err error
opts := defaultTmuxOptions(index) opts := defaultTmuxOptions(index)
tokens := splitRegexp.Split(arg, -1) tokens := splitRegexp.Split(arg, -1)
errorToReturn := errors.New("invalid popup option: " + arg + " (expected: [center|top|bottom|left|right][,SIZE[%]][,SIZE[%]][,border-native])") errorToReturn := errors.New("invalid tmux option: " + arg + " (expected: [center|top|bottom|left|right][,SIZE[%]][,SIZE[%][,border-native]])")
if len(tokens) == 0 || len(tokens) > 4 { if len(tokens) == 0 || len(tokens) > 4 {
return nil, errorToReturn return nil, errorToReturn
} }
@@ -519,7 +487,7 @@ func parseLabelPosition(opts *labelOpts, arg string) error {
} }
func (a previewOpts) aboveOrBelow() bool { func (a previewOpts) aboveOrBelow() bool {
return a.size.size > 0 && (a.position == posUp || a.position == posDown || a.position == posNext) return a.size.size > 0 && (a.position == posUp || a.position == posDown)
} }
type previewOptsCompare int type previewOptsCompare int
@@ -557,7 +525,7 @@ func (o *previewOpts) compare(active *previewOpts, b *previewOpts) previewOptsCo
return previewOptsDifferentLayout return previewOptsDifferentLayout
} }
if a.wrap == b.wrap && a.wrapWord == b.wrapWord && a.headerLines == b.headerLines && a.info == b.info && a.scroll == b.scroll { if a.wrap == b.wrap && a.headerLines == b.headerLines && a.info == b.info && a.scroll == b.scroll {
return previewOptsSame return previewOptsSame
} }
@@ -586,7 +554,6 @@ type Options struct {
Bash bool Bash bool
Zsh bool Zsh bool
Fish bool Fish bool
Nushell bool
Man bool Man bool
Fuzzy bool Fuzzy bool
FuzzyAlgo algo.Algo FuzzyAlgo algo.Algo
@@ -600,13 +567,11 @@ type Options struct {
FreezeLeft int FreezeLeft int
FreezeRight int FreezeRight int
WithNth func(Delimiter) func([]Token, int32) string WithNth func(Delimiter) func([]Token, int32) string
WithNthExpr string
AcceptNth func(Delimiter) func([]Token, int32) string AcceptNth func(Delimiter) func([]Token, int32) string
Delimiter Delimiter Delimiter Delimiter
Sort int Sort int
Raw bool Raw bool
Track trackOption Track trackOption
IdNth []Range
Tac bool Tac bool
Tail int Tail int
Criteria []criterion Criteria []criterion
@@ -622,9 +587,7 @@ type Options struct {
Layout layoutType Layout layoutType
Cycle bool Cycle bool
Wrap bool Wrap bool
WrapWord bool
WrapSign *string WrapSign *string
PreviewWrapSign *string
MultiLine bool MultiLine bool
CursorLine bool CursorLine bool
KeepRight bool KeepRight bool
@@ -692,8 +655,6 @@ type Options struct {
WalkerSkip []string WalkerSkip []string
Version bool Version bool
Help bool Help bool
Threads int
Bench time.Duration
CPUProfile string CPUProfile string
MEMProfile string MEMProfile string
BlockProfile string BlockProfile string
@@ -712,13 +673,7 @@ func filterNonEmpty(input []string) []string {
} }
func defaultPreviewOpts(command string) previewOpts { func defaultPreviewOpts(command string) previewOpts {
return previewOpts{ return previewOpts{command, posRight, sizeSpec{50, true}, "", false, false, false, false, true, defaultBorderShape, 0, 0, nil}
command: command,
position: posRight,
size: sizeSpec{50, true},
info: true,
border: defaultBorderShape,
}
} }
func defaultOptions() *Options { func defaultOptions() *Options {
@@ -734,7 +689,6 @@ func defaultOptions() *Options {
Bash: false, Bash: false,
Zsh: false, Zsh: false,
Fish: false, Fish: false,
Nushell: false,
Man: false, Man: false,
Fuzzy: true, Fuzzy: true,
FuzzyAlgo: algo.FuzzyMatchV2, FuzzyAlgo: algo.FuzzyMatchV2,
@@ -761,7 +715,6 @@ func defaultOptions() *Options {
Layout: layoutDefault, Layout: layoutDefault,
Cycle: false, Cycle: false,
Wrap: false, Wrap: false,
WrapWord: false,
MultiLine: true, MultiLine: true,
KeepRight: false, KeepRight: false,
Hscroll: true, Hscroll: true,
@@ -881,7 +834,7 @@ func nthTransformer(str string) (func(Delimiter) func([]Token, int32) string, er
nth []Range nth []Range
} }
parts := make([]NthParts, 0, len(indexes)) parts := make([]NthParts, len(indexes))
idx := 0 idx := 0
for _, index := range indexes { for _, index := range indexes {
if idx < index[0] { if idx < index[0] {
@@ -964,8 +917,6 @@ func parseBorder(str string, optional bool) (tui.BorderShape, error) {
switch str { switch str {
case "line": case "line":
return tui.BorderLine, nil return tui.BorderLine, nil
case "inline":
return tui.BorderInline, nil
case "rounded": case "rounded":
return tui.BorderRounded, nil return tui.BorderRounded, nil
case "sharp": case "sharp":
@@ -978,8 +929,6 @@ func parseBorder(str string, optional bool) (tui.BorderShape, error) {
return tui.BorderThinBlock, nil return tui.BorderThinBlock, nil
case "double": case "double":
return tui.BorderDouble, nil return tui.BorderDouble, nil
case "dashed":
return tui.BorderDashed, nil
case "horizontal": case "horizontal":
return tui.BorderHorizontal, nil return tui.BorderHorizontal, nil
case "vertical": case "vertical":
@@ -998,7 +947,7 @@ func parseBorder(str string, optional bool) (tui.BorderShape, error) {
if optional && str == "" { if optional && str == "" {
return defaultBorderShape, nil return defaultBorderShape, nil
} }
return tui.BorderNone, errors.New("invalid border style (expected: rounded|sharp|bold|block|thinblock|double|dashed|horizontal|vertical|top|bottom|left|right|line|inline|none)") return tui.BorderNone, errors.New("invalid border style (expected: rounded|sharp|bold|block|thinblock|double|horizontal|vertical|top|bottom|left|right|none)")
} }
func parseKeyChords(str string, message string) (map[tui.Event]string, []tui.Event, error) { func parseKeyChords(str string, message string) (map[tui.Event]string, []tui.Event, error) {
@@ -1063,8 +1012,6 @@ func parseKeyChords(str string, message string) (map[tui.Event]string, []tui.Eve
add(tui.Focus) add(tui.Focus)
case "result": case "result":
add(tui.Result) add(tui.Result)
case "result-final":
add(tui.ResultFinal)
case "resize": case "resize":
add(tui.Resize) add(tui.Resize)
case "one": case "one":
@@ -1269,15 +1216,8 @@ func parseKeyChords(str string, message string) (map[tui.Event]string, []tui.Eve
add(tui.F12) add(tui.F12)
default: default:
runes := []rune(key) runes := []rune(key)
if strings.HasPrefix(lkey, "every(") && strings.HasSuffix(lkey, ")") { if len(key) == 10 && strings.HasPrefix(lkey, "ctrl-alt-") && isAlphabet(lkey[9]) {
evt, err := parseEveryEvent(key[6 : len(key)-1]) r := rune(key[9])
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) evt := tui.CtrlAltKey(r)
if r == 'h' && !util.IsWindows() { if r == 'h' && !util.IsWindows() {
evt = tui.CtrlAltBackspace.AsEvent() evt = tui.CtrlAltBackspace.AsEvent()
@@ -1285,12 +1225,7 @@ func parseKeyChords(str string, message string) (map[tui.Event]string, []tui.Eve
chords[evt] = key chords[evt] = key
list = append(list, evt) list = append(list, evt)
} else if len(key) == 6 && strings.HasPrefix(lkey, "ctrl-") && isAlphabet(lkey[5]) { } else if len(key) == 6 && strings.HasPrefix(lkey, "ctrl-") && isAlphabet(lkey[5]) {
evt := tui.EventType(tui.CtrlA.Int() + int(lkey[5]) - 'a') add(tui.EventType(tui.CtrlA.Int() + int(lkey[5]) - 'a'))
r := rune(lkey[5])
if r == 'h' && !util.IsWindows() {
evt = tui.CtrlBackspace
}
add(evt)
} else if len(runes) == 5 && strings.HasPrefix(lkey, "alt-") { } else if len(runes) == 5 && strings.HasPrefix(lkey, "alt-") {
r := runes[4] r := runes[4]
switch r { switch r {
@@ -1318,21 +1253,6 @@ func parseKeyChords(str string, message string) (map[tui.Event]string, []tui.Eve
return chords, list, nil return chords, list, nil
} }
func parseEveryEvent(arg string) (tui.Event, error) {
secs, err := strconv.ParseFloat(strings.TrimSpace(arg), 64)
if err != nil || math.IsNaN(secs) || math.IsInf(secs, 0) || secs <= 0 {
return tui.Event{}, errors.New("every() requires a positive number of seconds")
}
if secs < 0.01 {
secs = 0.01
}
ms := math.Round(secs * 1000)
if ms > math.MaxInt32 {
return tui.Event{}, errors.New("every() interval is too large")
}
return tui.Event{Type: tui.Every, Char: rune(int32(ms))}, nil
}
func parseScheme(str string) (string, []criterion, error) { func parseScheme(str string) (string, []criterion, error) {
str = strings.ToLower(str) str = strings.ToLower(str)
switch str { switch str {
@@ -1464,14 +1384,6 @@ func parseTheme(defaultTheme *tui.ColorTheme, str string) (*tui.ColorTheme, *tui
cattr.Attr |= tui.Italic cattr.Attr |= tui.Italic
case "underline": case "underline":
cattr.Attr |= tui.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": case "blink":
cattr.Attr |= tui.Blink cattr.Attr |= tui.Blink
case "reverse": case "reverse":
@@ -1559,8 +1471,6 @@ func parseTheme(defaultTheme *tui.ColorTheme, str string) (*tui.ColorTheme, *tui
mergeAttr(&theme.Nomatch) mergeAttr(&theme.Nomatch)
case "gutter": case "gutter":
mergeAttr(&theme.Gutter) mergeAttr(&theme.Gutter)
case "alt-gutter":
mergeAttr(&theme.AltGutter)
case "hl": case "hl":
mergeAttr(&theme.Match) mergeAttr(&theme.Match)
case "current-hl", "hl+": case "current-hl", "hl+":
@@ -1606,7 +1516,7 @@ func parseTheme(defaultTheme *tui.ColorTheme, str string) (*tui.ColorTheme, *tui
case "info": case "info":
mergeAttr(&theme.Info) mergeAttr(&theme.Info)
case "pointer": case "pointer":
mergeAttr(&theme.Pointer) mergeAttr(&theme.Cursor)
case "marker": case "marker":
mergeAttr(&theme.Marker) mergeAttr(&theme.Marker)
case "header", "header-fg": case "header", "header-fg":
@@ -1652,7 +1562,7 @@ func parseWalkerOpts(str string) (walkerOpts, error) {
} }
var ( var (
argActionRegexp *regexp.Regexp executeRegexp *regexp.Regexp
splitRegexp *regexp.Regexp splitRegexp *regexp.Regexp
actionNameRegexp *regexp.Regexp actionNameRegexp *regexp.Regexp
) )
@@ -1671,8 +1581,8 @@ const (
) )
func init() { func init() {
argActionRegexp = regexp.MustCompile( 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-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)`) `(?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)`)
splitRegexp = regexp.MustCompile("[,:]+") splitRegexp = regexp.MustCompile("[,:]+")
actionNameRegexp = regexp.MustCompile("(?i)^[a-z-]+") actionNameRegexp = regexp.MustCompile("(?i)^[a-z-]+")
} }
@@ -1681,7 +1591,7 @@ func maskActionContents(action string) string {
masked := "" masked := ""
Loop: Loop:
for len(action) > 0 { for len(action) > 0 {
loc := argActionRegexp.FindStringIndex(action) loc := executeRegexp.FindStringIndex(action)
if loc == nil { if loc == nil {
masked += action masked += action
break break
@@ -1735,10 +1645,10 @@ Loop:
return masked return masked
} }
func parseSingleActionList(str string, putAllowed bool) ([]*action, error) { func parseSingleActionList(str string) ([]*action, error) {
// We prepend a colon to satisfy argActionRegexp and remove it later // We prepend a colon to satisfy executeRegexp and remove it later
masked := maskActionContents(":" + str)[1:] masked := maskActionContents(":" + str)[1:]
return parseActionList(masked, str, []*action{}, putAllowed) return parseActionList(masked, str, []*action{}, false)
} }
func parseActionList(masked string, original string, prevActions []*action, putAllowed bool) ([]*action, error) { func parseActionList(masked string, original string, prevActions []*action, putAllowed bool) ([]*action, error) {
@@ -1854,8 +1764,6 @@ func parseActionList(masked string, original string, prevActions []*action, putA
appendAction(actToggleHeader) appendAction(actToggleHeader)
case "toggle-wrap": case "toggle-wrap":
appendAction(actToggleWrap) appendAction(actToggleWrap)
case "toggle-wrap-word":
appendAction(actToggleWrapWord)
case "toggle-multi-line": case "toggle-multi-line":
appendAction(actToggleMultiLine) appendAction(actToggleMultiLine)
case "toggle-hscroll": case "toggle-hscroll":
@@ -1922,8 +1830,6 @@ func parseActionList(masked string, original string, prevActions []*action, putA
appendAction(actTogglePreview) appendAction(actTogglePreview)
case "toggle-preview-wrap": case "toggle-preview-wrap":
appendAction(actTogglePreviewWrap) appendAction(actTogglePreviewWrap)
case "toggle-preview-wrap-word":
appendAction(actTogglePreviewWrapWord)
case "toggle-sort": case "toggle-sort":
appendAction(actToggleSort) appendAction(actToggleSort)
case "offset-up": case "offset-up":
@@ -1958,8 +1864,6 @@ func parseActionList(masked string, original string, prevActions []*action, putA
} else { } else {
return nil, errors.New("unable to put non-printable character") return nil, errors.New("unable to put non-printable character")
} }
case "wait":
appendAction(actWait)
case "bell": case "bell":
appendAction(actBell) appendAction(actBell)
case "exclude": case "exclude":
@@ -2046,7 +1950,8 @@ func parseKeymap(keymap map[tui.Event][]*action, str string) error {
} }
key = firstKey(keys) key = firstKey(keys)
} }
keymap[key], err = parseActionList(pair[1], origPairStr[len(pair[0])+1:], keymap[key], key.Printable()) putAllowed := key.Type == tui.Rune && unicode.IsGraphic(key.Char)
keymap[key], err = parseActionList(pair[1], origPairStr[len(pair[0])+1:], keymap[key], putAllowed)
if err != nil { if err != nil {
return err return err
} }
@@ -2084,8 +1989,6 @@ func isExecuteAction(str string) actionType {
return actPreview return actPreview
case "change-header": case "change-header":
return actChangeHeader return actChangeHeader
case "change-header-lines":
return actChangeHeaderLines
case "change-footer": case "change-footer":
return actChangeFooter return actChangeFooter
case "change-list-label": case "change-list-label":
@@ -2116,8 +2019,6 @@ func isExecuteAction(str string) actionType {
return actChangeMulti return actChangeMulti
case "change-nth": case "change-nth":
return actChangeNth return actChangeNth
case "change-with-nth":
return actChangeWithNth
case "pos": case "pos":
return actPosition return actPosition
case "execute": case "execute":
@@ -2148,14 +2049,10 @@ func isExecuteAction(str string) actionType {
return actTransformFooter return actTransformFooter
case "transform-header": case "transform-header":
return actTransformHeader return actTransformHeader
case "transform-header-lines":
return actTransformHeaderLines
case "transform-ghost": case "transform-ghost":
return actTransformGhost return actTransformGhost
case "transform-nth": case "transform-nth":
return actTransformNth return actTransformNth
case "transform-with-nth":
return actTransformWithNth
case "transform-pointer": case "transform-pointer":
return actTransformPointer return actTransformPointer
case "transform-prompt": case "transform-prompt":
@@ -2182,14 +2079,10 @@ func isExecuteAction(str string) actionType {
return actBgTransformFooter return actBgTransformFooter
case "bg-transform-header": case "bg-transform-header":
return actBgTransformHeader return actBgTransformHeader
case "bg-transform-header-lines":
return actBgTransformHeaderLines
case "bg-transform-ghost": case "bg-transform-ghost":
return actBgTransformGhost return actBgTransformGhost
case "bg-transform-nth": case "bg-transform-nth":
return actBgTransformNth return actBgTransformNth
case "bg-transform-with-nth":
return actBgTransformWithNth
case "bg-transform-pointer": case "bg-transform-pointer":
return actBgTransformPointer return actBgTransformPointer
case "bg-transform-prompt": case "bg-transform-prompt":
@@ -2262,6 +2155,9 @@ func parseHeight(str string, index int) (heightSpec, error) {
str = str[1:] str = str[1:]
} }
if strings.HasPrefix(str, "-") { if strings.HasPrefix(str, "-") {
if heightSpec.auto {
return heightSpec, errors.New("negative(-) height is not compatible with adaptive(~) height")
}
heightSpec.inverse = true heightSpec.inverse = true
str = str[1:] str = str[1:]
} }
@@ -2345,13 +2241,8 @@ func parsePreviewWindowImpl(opts *previewOpts, input string) error {
opts.hidden = false opts.hidden = false
case "wrap": case "wrap":
opts.wrap = true opts.wrap = true
opts.wrapWord = false
case "wrap-word":
opts.wrap = true
opts.wrapWord = true
case "nowrap": case "nowrap":
opts.wrap = false opts.wrap = false
opts.wrapWord = false
case "cycle": case "cycle":
opts.cycle = true opts.cycle = true
case "nocycle": case "nocycle":
@@ -2364,8 +2255,6 @@ func parsePreviewWindowImpl(opts *previewOpts, input string) error {
opts.position = posLeft opts.position = posLeft
case "right": case "right":
opts.position = posRight opts.position = posRight
case "next":
opts.position = posNext
case "rounded", "border", "border-rounded": case "rounded", "border", "border-rounded":
opts.border = tui.BorderRounded opts.border = tui.BorderRounded
case "border-line": case "border-line":
@@ -2380,8 +2269,6 @@ func parsePreviewWindowImpl(opts *previewOpts, input string) error {
opts.border = tui.BorderThinBlock opts.border = tui.BorderThinBlock
case "border-double": case "border-double":
opts.border = tui.BorderDouble opts.border = tui.BorderDouble
case "border-dashed":
opts.border = tui.BorderDashed
case "noborder", "border-none": case "noborder", "border-none":
opts.border = tui.BorderNone opts.border = tui.BorderNone
case "border-horizontal": case "border-horizontal":
@@ -2558,7 +2445,6 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
opts.Bash = false opts.Bash = false
opts.Zsh = false opts.Zsh = false
opts.Fish = false opts.Fish = false
opts.Nushell = false
opts.Help = false opts.Help = false
opts.Version = false opts.Version = false
opts.Man = false opts.Man = false
@@ -2671,9 +2557,6 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
case "--fish": case "--fish":
clearExitingOpts() clearExitingOpts()
opts.Fish = true opts.Fish = true
case "--nushell":
clearExitingOpts()
opts.Nushell = true
case "-h", "--help": case "-h", "--help":
clearExitingOpts() clearExitingOpts()
opts.Help = true opts.Help = true
@@ -2682,7 +2565,7 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
opts.Version = true opts.Version = true
case "--no-winpty": case "--no-winpty":
opts.NoWinpty = true opts.NoWinpty = true
case "--tmux", "--popup": case "--tmux":
given, str := optionalNextString() given, str := optionalNextString()
if given { if given {
if opts.Tmux, err = parseTmuxOptions(str, index); err != nil { if opts.Tmux, err = parseTmuxOptions(str, index); err != nil {
@@ -2691,7 +2574,7 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
} else { } else {
opts.Tmux = defaultTmuxOptions(index) opts.Tmux = defaultTmuxOptions(index)
} }
case "--no-tmux", "--no-popup": case "--no-tmux":
opts.Tmux = nil opts.Tmux = nil
case "--tty-default": case "--tty-default":
if opts.TtyDefault, err = nextString("tty device name required"); err != nil { if opts.TtyDefault, err = nextString("tty device name required"); err != nil {
@@ -2836,7 +2719,6 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
if opts.WithNth, err = nthTransformer(str); err != nil { if opts.WithNth, err = nthTransformer(str); err != nil {
return err return err
} }
opts.WithNthExpr = str
case "--accept-nth": case "--accept-nth":
str, err := nextString("nth expression required") str, err := nextString("nth expression required")
if err != nil { if err != nil {
@@ -2859,16 +2741,6 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
opts.Track = trackEnabled opts.Track = trackEnabled
case "--no-track": case "--no-track":
opts.Track = trackDisabled opts.Track = trackDisabled
case "--id-nth":
str, err := nextString("nth expression required")
if err != nil {
return err
}
if opts.IdNth, err = splitNth(str); err != nil {
return err
}
case "--no-id-nth":
opts.IdNth = nil
case "--tac": case "--tac":
opts.Tac = true opts.Tac = true
case "--no-tac": case "--no-tac":
@@ -2934,29 +2806,9 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
case "--no-cycle": case "--no-cycle":
opts.Cycle = false opts.Cycle = false
case "--wrap": case "--wrap":
given, str := optionalNextString() opts.Wrap = true
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": case "--no-wrap":
opts.Wrap = false 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": case "--wrap-sign":
str, err := nextString("wrap sign required") str, err := nextString("wrap sign required")
if err != nil { if err != nil {
@@ -3176,7 +3028,7 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
case "--no-preview": case "--no-preview":
opts.Preview.command = "" opts.Preview.command = ""
case "--preview-window": case "--preview-window":
str, err := nextString("preview window layout required: [up|down|left|right|next][,SIZE[%]][,border-STYLE][,wrap][,cycle][,hidden][,+SCROLL[OFFSETS][/DENOM]][,~HEADER_LINES][,default]") str, err := nextString("preview window layout required: [up|down|left|right][,SIZE[%]][,border-STYLE][,wrap][,cycle][,hidden][,+SCROLL[OFFSETS][/DENOM]][,~HEADER_LINES][,default]")
if err != nil { if err != nil {
return err return err
} }
@@ -3190,14 +3042,8 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
if opts.Preview.border, err = parseBorder(arg, !hasArg); err != nil { if opts.Preview.border, err = parseBorder(arg, !hasArg); err != nil {
return err return err
} }
case "--preview-wrap-sign":
str, err := nextString("preview wrap sign required")
if err != nil {
return err
}
opts.PreviewWrapSign = &str
case "--height": case "--height":
str, err := nextString("height required: [~][-]HEIGHT[%]") str, err := nextString("height required: [~]HEIGHT[%]")
if err != nil { if err != nil {
return err return err
} }
@@ -3442,23 +3288,6 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
return err return err
} }
opts.WalkerSkip = filterNonEmpty(strings.Split(str, ",")) opts.WalkerSkip = filterNonEmpty(strings.Split(str, ","))
case "--threads":
if opts.Threads, err = nextInt("number of threads required"); err != nil {
return err
}
if opts.Threads < 0 {
return errors.New("--threads must be a positive integer")
}
case "--bench":
str, err := nextString("duration required (e.g. 3s, 500ms)")
if err != nil {
return err
}
dur, err := time.ParseDuration(str)
if err != nil {
return errors.New("invalid duration for --bench: " + str)
}
opts.Bench = dur
case "--profile-cpu": case "--profile-cpu":
if opts.CPUProfile, err = nextString("file path required: cpu"); err != nil { if opts.CPUProfile, err = nextString("file path required: cpu"); err != nil {
return err return err
@@ -3564,9 +3393,7 @@ func applyPreset(opts *Options, preset string) error {
opts.Preview.border = tui.BorderLine opts.Preview.border = tui.BorderLine
opts.Preview.info = false opts.Preview.info = false
opts.InfoStyle = infoDefault opts.InfoStyle = infoDefault
opts.Theme.Gutter = tui.NewColorAttr() opts.Theme.Gutter = tui.ColorAttr{Color: -1, Attr: 0}
space := " "
opts.Gutter = &space
empty := "" empty := ""
opts.Separator = &empty opts.Separator = &empty
opts.Scrollbar = &empty opts.Scrollbar = &empty
@@ -3641,7 +3468,7 @@ func validateOptions(opts *Options) error {
} }
} }
if opts.Height.auto && (opts.Tmux == nil || opts.Tmux.index < opts.Height.index) { if opts.Height.auto {
for _, s := range []sizeSpec{opts.Margin[0], opts.Margin[2]} { for _, s := range []sizeSpec{opts.Margin[0], opts.Margin[2]} {
if s.percent { if s.percent {
return errors.New("adaptive height is not compatible with top/bottom percent margin") return errors.New("adaptive height is not compatible with top/bottom percent margin")
@@ -3658,19 +3485,6 @@ func validateOptions(opts *Options) error {
return errors.New("only ANSI attributes are allowed for 'nth' (regular, bold, underline, reverse, dim, italic, strikethrough)") return errors.New("only ANSI attributes are allowed for 'nth' (regular, bold, underline, reverse, dim, italic, strikethrough)")
} }
if opts.BorderShape == tui.BorderInline ||
opts.ListBorderShape == tui.BorderInline ||
opts.InputBorderShape == tui.BorderInline ||
opts.Preview.border == tui.BorderInline {
return errors.New("inline border is only supported for --header-border, --header-lines-border, and --footer-border")
}
if opts.HeaderBorderShape == tui.BorderInline &&
opts.HeaderLinesShape != tui.BorderInline &&
opts.HeaderLinesShape != tui.BorderUndefined &&
opts.HeaderLinesShape != tui.BorderNone {
return errors.New("--header-border=inline requires --header-lines-border to be inline or unset")
}
return nil return nil
} }
@@ -3688,10 +3502,6 @@ func (opts *Options) useTmux() bool {
return opts.Tmux != nil && len(os.Getenv("TMUX")) > 0 && opts.Tmux.index >= opts.Height.index return opts.Tmux != nil && len(os.Getenv("TMUX")) > 0 && opts.Tmux.index >= opts.Height.index
} }
func (opts *Options) useZellij() bool {
return opts.Tmux != nil && len(os.Getenv("ZELLIJ")) > 0 && opts.Tmux.index >= opts.Height.index
}
func (opts *Options) noSeparatorLine() bool { func (opts *Options) noSeparatorLine() bool {
if opts.Inputless { if opts.Inputless {
return true return true
+2 -93
View File
@@ -299,39 +299,6 @@ func TestBind(t *testing.T) {
check(tui.F1.AsEvent(), "", actAbort) check(tui.F1.AsEvent(), "", actAbort)
} }
func TestParseEveryEvent(t *testing.T) {
pairs, _, err := parseKeyChords("every(2),every(0.5)", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(pairs) != 2 {
t.Errorf("expected 2 distinct every events, got %d", len(pairs))
}
if pairs[(tui.Event{Type: tui.Every, Char: 2000})] != "every(2)" {
t.Errorf("every(2) not registered")
}
if pairs[(tui.Event{Type: tui.Every, Char: 500})] != "every(0.5)" {
t.Errorf("every(0.5) not registered")
}
// Floor at 0.01s -> 10ms
pairs, _, err = parseKeyChords("every(0.001)", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if pairs[(tui.Event{Type: tui.Every, Char: 10})] != "every(0.001)" {
t.Errorf("every(0.001) should floor to 10ms")
}
// Reject zero, negatives, and overflow (>= 2^31 ms = ~24.85 days)
for _, bad := range []string{"every(0)", "every(-1)", "every(abc)", "every()", "every(2147484)"} {
if _, _, err := parseKeyChords(bad, ""); err == nil {
t.Errorf("%s should be rejected", bad)
}
}
}
func TestColorSpec(t *testing.T) { func TestColorSpec(t *testing.T) {
var base *tui.ColorTheme var base *tui.ColorTheme
theme := tui.Dark256 theme := tui.Dark256
@@ -481,64 +448,6 @@ func TestPreviewOpts(t *testing.T) {
opts.Preview.size.size == 70) { opts.Preview.size.size == 70) {
t.Error(opts.Preview) 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) { func TestAdditiveExpect(t *testing.T) {
@@ -572,7 +481,7 @@ func TestValidateSign(t *testing.T) {
} }
func TestParseSingleActionList(t *testing.T) { func TestParseSingleActionList(t *testing.T) {
actions, _ := parseSingleActionList("Execute@foo+bar,baz@+up+up+reload:down+down", false) actions, _ := parseSingleActionList("Execute@foo+bar,baz@+up+up+reload:down+down")
if len(actions) != 4 { if len(actions) != 4 {
t.Errorf("Invalid number of actions parsed:%d", len(actions)) t.Errorf("Invalid number of actions parsed:%d", len(actions))
} }
@@ -588,7 +497,7 @@ func TestParseSingleActionList(t *testing.T) {
} }
func TestParseSingleActionListError(t *testing.T) { func TestParseSingleActionListError(t *testing.T) {
_, err := parseSingleActionList("change-query(foobar)baz", false) _, err := parseSingleActionList("change-query(foobar)baz")
if err == nil { if err == nil {
t.Errorf("Failed to detect error") t.Errorf("Failed to detect error")
} }
+52 -93
View File
@@ -61,12 +61,9 @@ type Pattern struct {
delimiter Delimiter delimiter Delimiter
nth []Range nth []Range
revision revision revision revision
procFun [6]algo.Algo procFun map[termType]algo.Algo
cache *ChunkCache cache *ChunkCache
denylist map[int32]struct{} denylist map[int32]struct{}
startIndex int32
directAlgo algo.Algo
directTerm *term
} }
var _splitRegex *regexp.Regexp var _splitRegex *regexp.Regexp
@@ -77,7 +74,7 @@ func init() {
// BuildPattern builds Pattern object from the given arguments // BuildPattern builds Pattern object from the given arguments
func BuildPattern(cache *ChunkCache, patternCache map[string]*Pattern, fuzzy bool, fuzzyAlgo algo.Algo, extended bool, caseMode Case, normalize bool, forward bool, func BuildPattern(cache *ChunkCache, patternCache map[string]*Pattern, fuzzy bool, fuzzyAlgo algo.Algo, extended bool, caseMode Case, normalize bool, forward bool,
withPos bool, cacheable bool, nth []Range, delimiter Delimiter, revision revision, runes []rune, denylist map[int32]struct{}, startIndex int32) *Pattern { withPos bool, cacheable bool, nth []Range, delimiter Delimiter, revision revision, runes []rune, denylist map[int32]struct{}) *Pattern {
var asString string var asString string
if extended { if extended {
@@ -149,11 +146,9 @@ func BuildPattern(cache *ChunkCache, patternCache map[string]*Pattern, fuzzy boo
delimiter: delimiter, delimiter: delimiter,
cache: cache, cache: cache,
denylist: denylist, denylist: denylist,
startIndex: startIndex, procFun: make(map[termType]algo.Algo)}
}
ptr.cacheKey = ptr.buildCacheKey() ptr.cacheKey = ptr.buildCacheKey()
ptr.directAlgo, ptr.directTerm = ptr.buildDirectAlgo(fuzzyAlgo)
ptr.procFun[termFuzzy] = fuzzyAlgo ptr.procFun[termFuzzy] = fuzzyAlgo
ptr.procFun[termEqual] = algo.EqualMatch ptr.procFun[termEqual] = algo.EqualMatch
ptr.procFun[termExact] = algo.ExactMatchNaive ptr.procFun[termExact] = algo.ExactMatchNaive
@@ -277,22 +272,6 @@ func (p *Pattern) buildCacheKey() string {
return strings.Join(cacheableTerms, "\t") return strings.Join(cacheableTerms, "\t")
} }
// buildDirectAlgo returns the algo function and term for the direct fast path
// in matchChunk. Returns (nil, nil) if the pattern is not suitable.
// Requirements: extended mode, single term set with single non-inverse fuzzy term, no nth.
func (p *Pattern) buildDirectAlgo(fuzzyAlgo algo.Algo) (algo.Algo, *term) {
if !p.extended || len(p.nth) > 0 {
return nil, nil
}
if len(p.termSets) == 1 && len(p.termSets[0]) == 1 {
t := &p.termSets[0][0]
if !t.inv && t.typ == termFuzzy {
return fuzzyAlgo, t
}
}
return nil, nil
}
// CacheKey is used to build string to be used as the key of result cache // CacheKey is used to build string to be used as the key of result cache
func (p *Pattern) CacheKey() string { func (p *Pattern) CacheKey() string {
return p.cacheKey return p.cacheKey
@@ -300,104 +279,84 @@ func (p *Pattern) CacheKey() string {
// Match returns the list of matches Items in the given Chunk // Match returns the list of matches Items in the given Chunk
func (p *Pattern) Match(chunk *Chunk, slab *util.Slab) []Result { func (p *Pattern) Match(chunk *Chunk, slab *util.Slab) []Result {
// ChunkCache: Exact match
cacheKey := p.CacheKey() cacheKey := p.CacheKey()
// Bitmap cache: exact match or prefix/suffix
var cachedBitmap *ChunkBitmap
if p.cacheable { if p.cacheable {
cachedBitmap = p.cache.Lookup(chunk, cacheKey) if cached := p.cache.Lookup(chunk, cacheKey); cached != nil {
} return cached
if cachedBitmap == nil { }
cachedBitmap = p.cache.Search(chunk, cacheKey)
} }
matches, bitmap := p.matchChunk(chunk, cachedBitmap, slab) // Prefix/suffix cache
space := p.cache.Search(chunk, cacheKey)
matches := p.matchChunk(chunk, space, slab)
if p.cacheable { if p.cacheable {
p.cache.Add(chunk, cacheKey, bitmap, len(matches)) p.cache.Add(chunk, cacheKey, matches)
} }
return matches return matches
} }
func (p *Pattern) matchChunk(chunk *Chunk, cachedBitmap *ChunkBitmap, slab *util.Slab) ([]Result, ChunkBitmap) { func (p *Pattern) matchChunk(chunk *Chunk, space []Result, slab *util.Slab) []Result {
matches := []Result{} matches := []Result{}
var bitmap ChunkBitmap
// Skip header items in chunks that contain them
startIdx := 0
if p.startIndex > 0 && chunk.count > 0 && chunk.items[0].Index() < p.startIndex {
startIdx = int(p.startIndex - chunk.items[0].Index())
if startIdx >= chunk.count {
return matches, bitmap
}
}
hasCachedBitmap := cachedBitmap != nil
// Fast path: single fuzzy term, no nth, no denylist.
// Calls the algo function directly, bypassing MatchItem/extendedMatch/iter
// and avoiding per-match []Offset heap allocation.
if p.directAlgo != nil && len(p.denylist) == 0 {
t := p.directTerm
for idx := startIdx; idx < chunk.count; idx++ {
if hasCachedBitmap && cachedBitmap[idx/64]&(uint64(1)<<(idx%64)) == 0 {
continue
}
res, _ := p.directAlgo(t.caseSensitive, t.normalize, p.forward,
&chunk.items[idx].text, t.text, p.withPos, slab)
if res.Start >= 0 {
bitmap[idx/64] |= uint64(1) << (idx % 64)
matches = append(matches, buildResultFromBounds(
&chunk.items[idx], res.Score,
int(res.Start), int(res.End), int(res.End), true))
}
}
return matches, bitmap
}
if len(p.denylist) == 0 { if len(p.denylist) == 0 {
for idx := startIdx; idx < chunk.count; idx++ { // Huge code duplication for minimizing unnecessary map lookups
if hasCachedBitmap && cachedBitmap[idx/64]&(uint64(1)<<(idx%64)) == 0 { 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)
}
}
} else {
for _, result := range space {
if match, _, _ := p.MatchItem(result.item, p.withPos, slab); match != nil {
matches = append(matches, *match)
}
}
}
return matches
}
if space == nil {
for idx := 0; idx < chunk.count; idx++ {
if _, prs := p.denylist[chunk.items[idx].Index()]; prs {
continue continue
} }
if match, _, _ := p.MatchItem(&chunk.items[idx], p.withPos, slab); match.item != nil { if match, _, _ := p.MatchItem(&chunk.items[idx], p.withPos, slab); match != nil {
bitmap[idx/64] |= uint64(1) << (idx % 64) matches = append(matches, *match)
matches = append(matches, match)
} }
} }
return matches, bitmap } else {
} for _, result := range space {
if _, prs := p.denylist[result.item.Index()]; prs {
for idx := startIdx; idx < chunk.count; idx++ { continue
if hasCachedBitmap && cachedBitmap[idx/64]&(uint64(1)<<(idx%64)) == 0 { }
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, bitmap return matches
} }
// MatchItem returns the match result if the Item is a match. // MatchItem returns true 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) {
func (p *Pattern) MatchItem(item *Item, withPos bool, slab *util.Slab) (Result, []Offset, *[]int) {
if p.extended { if p.extended {
if offsets, bonus, pos := p.extendedMatch(item, withPos, slab); len(offsets) == len(p.termSets) { if offsets, bonus, pos := p.extendedMatch(item, withPos, slab); len(offsets) == len(p.termSets) {
return buildResult(item, offsets, bonus), offsets, pos result := buildResult(item, offsets, bonus)
return &result, offsets, pos
} }
return Result{}, nil, nil return nil, nil, nil
} }
offset, bonus, pos := p.basicMatch(item, withPos, slab) offset, bonus, pos := p.basicMatch(item, withPos, slab)
if sidx := offset[0]; sidx >= 0 { if sidx := offset[0]; sidx >= 0 {
offsets := []Offset{offset} offsets := []Offset{offset}
return buildResult(item, offsets, bonus), offsets, pos result := buildResult(item, offsets, bonus)
return &result, offsets, pos
} }
return Result{}, nil, nil return nil, nil, nil
} }
func (p *Pattern) basicMatch(item *Item, withPos bool, slab *util.Slab) (Offset, int, *[]int) { func (p *Pattern) basicMatch(item *Item, withPos bool, slab *util.Slab) (Offset, int, *[]int) {
+2 -119
View File
@@ -2,7 +2,6 @@ package fzf
import ( import (
"reflect" "reflect"
"runtime"
"testing" "testing"
"github.com/junegunn/fzf/src/algo" "github.com/junegunn/fzf/src/algo"
@@ -69,7 +68,7 @@ func buildPattern(fuzzy bool, fuzzyAlgo algo.Algo, extended bool, caseMode Case,
withPos bool, cacheable bool, nth []Range, delimiter Delimiter, runes []rune) *Pattern { withPos bool, cacheable bool, nth []Range, delimiter Delimiter, runes []rune) *Pattern {
return BuildPattern(NewChunkCache(), make(map[string]*Pattern), return BuildPattern(NewChunkCache(), make(map[string]*Pattern),
fuzzy, fuzzyAlgo, extended, caseMode, normalize, forward, fuzzy, fuzzyAlgo, extended, caseMode, normalize, forward,
withPos, cacheable, nth, delimiter, revision{}, runes, nil, 0) withPos, cacheable, nth, delimiter, revision{}, runes, nil)
} }
func TestExact(t *testing.T) { func TestExact(t *testing.T) {
@@ -138,7 +137,7 @@ func TestOrigTextAndTransformed(t *testing.T) {
origText: &origBytes, origText: &origBytes,
transformed: &transformed{pattern.revision, trans}} transformed: &transformed{pattern.revision, trans}}
pattern.extended = extended pattern.extended = extended
matches, _ := pattern.matchChunk(&chunk, nil, slab) // No cache matches := pattern.matchChunk(&chunk, nil, slab) // No cache
if !(matches[0].item.text.ToString() == "junegunn" && if !(matches[0].item.text.ToString() == "junegunn" &&
string(*matches[0].item.origText) == "junegunn.choi" && string(*matches[0].item.origText) == "junegunn.choi" &&
reflect.DeepEqual((*matches[0].item.transformed).tokens, trans)) { reflect.DeepEqual((*matches[0].item.transformed).tokens, trans)) {
@@ -200,119 +199,3 @@ func TestCacheable(t *testing.T) {
test(false, "foo 'bar", "foo", false) test(false, "foo 'bar", "foo", false)
test(false, "foo !bar", "foo", false) test(false, "foo !bar", "foo", false)
} }
func buildChunks(numChunks int) []*Chunk {
chunks := make([]*Chunk, numChunks)
words := []string{
"src/main/java/com/example/service/UserService.java",
"src/test/java/com/example/service/UserServiceTest.java",
"docs/api/reference/endpoints.md",
"lib/internal/utils/string_helper.go",
"pkg/server/http/handler/auth.go",
"build/output/release/app.exe",
"config/production/database.yml",
"scripts/deploy/kubernetes/setup.sh",
"vendor/github.com/junegunn/fzf/src/core.go",
"node_modules/.cache/babel/transform.js",
}
for ci := range numChunks {
chunks[ci] = &Chunk{count: chunkSize}
for i := range chunkSize {
text := words[(ci*chunkSize+i)%len(words)]
chunks[ci].items[i] = Item{text: util.ToChars([]byte(text))}
chunks[ci].items[i].text.Index = int32(ci*chunkSize + i)
}
}
return chunks
}
func buildPatternWith(cache *ChunkCache, runes []rune) *Pattern {
return BuildPattern(cache, make(map[string]*Pattern),
true, algo.FuzzyMatchV2, true, CaseSmart, false, true,
false, true, []Range{}, Delimiter{}, revision{}, runes, nil, 0)
}
func TestBitmapCacheBenefit(t *testing.T) {
numChunks := 100
chunks := buildChunks(numChunks)
queries := []string{"s", "se", "ser", "serv", "servi"}
// 1. Run all queries with shared cache (simulates incremental typing)
cache := NewChunkCache()
for _, q := range queries {
pat := buildPatternWith(cache, []rune(q))
for _, chunk := range chunks {
pat.Match(chunk, slab)
}
}
// 2. GC and measure memory with cache populated
runtime.GC()
runtime.GC()
var memWith runtime.MemStats
runtime.ReadMemStats(&memWith)
// 3. Clear cache, GC, measure again
cache.Clear()
runtime.GC()
runtime.GC()
var memWithout runtime.MemStats
runtime.ReadMemStats(&memWithout)
cacheMem := int64(memWith.Alloc) - int64(memWithout.Alloc)
t.Logf("Chunks: %d, Queries: %d", numChunks, len(queries))
t.Logf("Cache memory: %d bytes (%.1f KB)", cacheMem, float64(cacheMem)/1024)
t.Logf("Per-chunk-per-query: %.0f bytes", float64(cacheMem)/float64(numChunks*len(queries)))
// 4. Verify correctness: cached vs uncached produce same results
cache2 := NewChunkCache()
for _, q := range queries {
pat := buildPatternWith(cache2, []rune(q))
for _, chunk := range chunks {
pat.Match(chunk, slab)
}
}
for _, q := range queries {
patCached := buildPatternWith(cache2, []rune(q))
patFresh := buildPatternWith(NewChunkCache(), []rune(q))
var countCached, countFresh int
for _, chunk := range chunks {
countCached += len(patCached.Match(chunk, slab))
countFresh += len(patFresh.Match(chunk, slab))
}
if countCached != countFresh {
t.Errorf("query=%q: cached=%d, fresh=%d", q, countCached, countFresh)
}
t.Logf("query=%q: matches=%d", q, countCached)
}
}
func BenchmarkWithCache(b *testing.B) {
numChunks := 100
chunks := buildChunks(numChunks)
queries := []string{"s", "se", "ser", "serv", "servi"}
b.Run("cached", func(b *testing.B) {
for range b.N {
cache := NewChunkCache()
for _, q := range queries {
pat := buildPatternWith(cache, []rune(q))
for _, chunk := range chunks {
pat.Match(chunk, slab)
}
}
}
})
b.Run("uncached", func(b *testing.B) {
for range b.N {
for _, q := range queries {
cache := NewChunkCache()
pat := buildPatternWith(cache, []rune(q))
for _, chunk := range chunks {
pat.Match(chunk, slab)
}
}
}
})
}
+1 -1
View File
@@ -6,5 +6,5 @@ import "golang.org/x/sys/unix"
// Protect calls OS specific protections like pledge on OpenBSD // Protect calls OS specific protections like pledge on OpenBSD
func Protect() { func Protect() {
unix.PledgePromises("stdio cpath dpath wpath rpath inet fattr unix tty proc exec") unix.PledgePromises("stdio dpath wpath rpath tty proc exec inet tmppath")
} }
+3 -39
View File
@@ -23,32 +23,6 @@ func escapeSingleQuote(str string) string {
return "'" + strings.ReplaceAll(str, "'", "'\\''") + "'" 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) { func fifo(name string) (string, error) {
ns := time.Now().UnixNano() ns := time.Now().UnixNano()
output := filepath.Join(os.TempDir(), fmt.Sprintf("fzf-%s-%d", name, ns)) output := filepath.Join(os.TempDir(), fmt.Sprintf("fzf-%s-%d", name, ns))
@@ -138,17 +112,6 @@ func runProxy(commandPrefix string, cmdBuilder func(temp string, needBash bool)
temp := WriteTemporaryFile(append(exports, command), "\n") temp := WriteTemporaryFile(append(exports, command), "\n")
defer os.Remove(temp) defer os.Remove(temp)
// Pre-create the become file so that another user on a shared TMPDIR
// cannot plant a file or a symbolic link at the predictable path while
// fzf is running
becomeFile := temp + becomeSuffix
becomeF, err := os.OpenFile(becomeFile, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return ExitError, err
}
becomeF.Close()
defer os.Remove(becomeFile)
cmd, err := cmdBuilder(temp, needBash) cmd, err := cmdBuilder(temp, needBash)
if err != nil { if err != nil {
return ExitError, err return ExitError, err
@@ -166,15 +129,16 @@ func runProxy(commandPrefix string, cmdBuilder func(temp string, needBash bool)
if exitError, ok := err.(*exec.ExitError); ok { if exitError, ok := err.(*exec.ExitError); ok {
code := exitError.ExitCode() code := exitError.ExitCode()
if code == ExitBecome { if code == ExitBecome {
becomeFile := temp + becomeSuffix
data, err := os.ReadFile(becomeFile) data, err := os.ReadFile(becomeFile)
os.Remove(becomeFile) os.Remove(becomeFile)
if err != nil { if err != nil {
return ExitError, err return ExitError, err
} }
if len(data) == 0 { elems := strings.Split(string(data), "\x00")
if len(elems) < 1 {
return ExitError, errors.New("invalid become command") return ExitError, errors.New("invalid become command")
} }
elems := strings.Split(string(data), "\x00")
command := elems[0] command := elems[0]
env := []string{} env := []string{}
if len(elems) > 1 { if len(elems) > 1 {
+3 -43
View File
@@ -178,7 +178,7 @@ func (r *Reader) feed(src io.Reader) {
var err error var err error
for { for {
n := 0 n := 0
scope := slab[:min(len(slab), readerBufferSize)] scope := slab[:util.Min(len(slab), readerBufferSize)]
for range 100 { for range 100 {
n, err = src.Read(scope) n, err = src.Read(scope)
if n > 0 || err != nil { if n > 0 || err != nil {
@@ -274,24 +274,6 @@ func (r *Reader) readFiles(roots []string, opts walkerOpts, ignores []string) bo
ToSlash: fastwalk.DefaultToSlash(), ToSlash: fastwalk.DefaultToSlash(),
Sort: fastwalk.SortFilesFirst, Sort: fastwalk.SortFilesFirst,
} }
// When following symlinks, precompute the absolute real paths of walker
// roots so we can skip symlinks that point to an ancestor. fastwalk's
// built-in loop detection (shouldTraverse) catches loops on the second
// pass, but a single pass through a symlink like z: -> / already
// traverses the entire root filesystem, causing severe resource
// exhaustion. Skipping ancestor symlinks prevents this entirely.
var absRoots []string
if opts.follow {
for _, root := range roots {
if real, err := filepath.EvalSymlinks(root); err == nil {
if abs, err := filepath.Abs(real); err == nil {
absRoots = append(absRoots, filepath.Clean(abs))
}
}
}
}
ignoresBase := []string{} ignoresBase := []string{}
ignoresFull := []string{} ignoresFull := []string{}
ignoresSuffix := []string{} ignoresSuffix := []string{}
@@ -321,30 +303,8 @@ func (r *Reader) readFiles(roots []string, opts walkerOpts, ignores []string) bo
} }
path = trimPath(path) path = trimPath(path)
if path != "." { if path != "." {
isDirSymlink := isSymlinkToDir(path, de) isDir := de.IsDir()
if isDirSymlink && !opts.follow { if isDir || opts.follow && isSymlinkToDir(path, de) {
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) base := filepath.Base(path)
if !opts.hidden && base[0] == '.' && base != ".." { if !opts.hidden && base[0] == '.' && base != ".." {
return filepath.SkipDir return filepath.SkipDir
+30 -118
View File
@@ -2,7 +2,6 @@ package fzf
import ( import (
"math" "math"
"slices"
"sort" "sort"
"unicode" "unicode"
@@ -31,9 +30,11 @@ type Result struct {
func buildResult(item *Item, offsets []Offset, score int) Result { func buildResult(item *Item, offsets []Offset, score int) Result {
if len(offsets) > 1 { if len(offsets) > 1 {
slices.SortFunc(offsets, compareOffsets) sort.Sort(ByOrder(offsets))
} }
result := Result{item: item}
numChars := item.text.Length()
minBegin := math.MaxUint16 minBegin := math.MaxUint16
minEnd := math.MaxUint16 minEnd := math.MaxUint16
maxEnd := 0 maxEnd := 0
@@ -41,21 +42,13 @@ func buildResult(item *Item, offsets []Offset, score int) Result {
for _, offset := range offsets { for _, offset := range offsets {
b, e := int(offset[0]), int(offset[1]) b, e := int(offset[0]), int(offset[1])
if b < e { if b < e {
minBegin = min(b, minBegin) minBegin = util.Min(b, minBegin)
minEnd = min(e, minEnd) minEnd = util.Min(e, minEnd)
maxEnd = max(e, maxEnd) maxEnd = util.Max(e, maxEnd)
validOffsetFound = true 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 { for idx, criterion := range sortCriteria {
val := uint16(math.MaxUint16) val := uint16(math.MaxUint16)
switch criterion { switch criterion {
@@ -82,6 +75,7 @@ func buildResultFromBounds(item *Item, score int, minBegin, minEnd, maxEnd int,
val = item.TrimLength() val = item.TrimLength()
case byPathname: case byPathname:
if validOffsetFound { if validOffsetFound {
// lastDelim := strings.LastIndexByte(item.text.ToString(), '/')
lastDelim := -1 lastDelim := -1
s := item.text.ToString() s := item.text.ToString()
for i := len(s) - 1; i >= 0; i-- { for i := len(s) - 1; i >= 0; i-- {
@@ -129,7 +123,7 @@ func minRank() Result {
return Result{item: &minItem, points: [4]uint16{math.MaxUint16, 0, 0, 0}} return Result{item: &minItem, points: [4]uint16{math.MaxUint16, 0, 0, 0}}
} }
func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, theme *tui.ColorTheme, colBase tui.ColorPair, colMatch tui.ColorPair, attrNth tui.Attr, nthOverlay 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, hidden bool) []colorOffset {
itemColors := result.item.Colors() itemColors := result.item.Colors()
// No ANSI codes // No ANSI codes
@@ -188,7 +182,7 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
} }
} }
// slices.SortFunc(offsets, compareOffsets) // sort.Sort(ByOrder(offsets))
// Merge offsets // Merge offsets
// ------------ ---- -- ---- // ------------ ---- -- ----
@@ -198,10 +192,7 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
start := 0 start := 0
ansiToColorPair := func(ansi ansiOffset, base tui.ColorPair) tui.ColorPair { ansiToColorPair := func(ansi ansiOffset, base tui.ColorPair) tui.ColorPair {
if !theme.Colored { if !theme.Colored {
// Ignore ANSI colors but keep the attributes. Retain the base return tui.NewColorPair(-1, -1, ansi.color.attr).MergeAttr(base)
// colors (e.g. an overridden input-bg or list-bg) instead of
// resetting to the terminal default.
return tui.NewColorPair(base.Fg(), base.Bg(), ansi.color.attr).MergeAttr(base)
} }
// fd --color always | fzf --ansi --delimiter / --nth -1 --color fg:dim:strip,nth:regular // fd --color always | fzf --ansi --delimiter / --nth -1 --color fg:dim:strip,nth:regular
if base.ShouldStripColors() { if base.ShouldStripColors() {
@@ -215,12 +206,8 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
if bg == -1 { if bg == -1 {
bg = colBase.Bg() bg = colBase.Bg()
} }
return tui.NewColorPair(fg, bg, ansi.color.attr).WithUl(ansi.color.ul).MergeAttr(base) return tui.NewColorPair(fg, bg, ansi.color.attr).MergeAttr(base)
} }
fgAttr := tui.ColNormal.Attr()
nthAttrFinal := fgAttr.Merge(attrNth).Merge(nthOverlay)
nthBase := colBase.WithNewAttr(nthAttrFinal)
var colors []colorOffset var colors []colorOffset
add := func(idx int) { add := func(idx int) {
if curr.fbg >= 0 { if curr.fbg >= 0 {
@@ -234,7 +221,7 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
if curr.match { if curr.match {
var color tui.ColorPair var color tui.ColorPair
if curr.nth { if curr.nth {
color = nthBase.Merge(colMatch) color = colBase.WithAttr(attrNth).Merge(colMatch)
} else { } else {
color = colBase.Merge(colMatch) color = colBase.Merge(colMatch)
} }
@@ -254,7 +241,7 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
if color.Fg().IsDefault() && origColor.HasBg() { if color.Fg().IsDefault() && origColor.HasBg() {
color = origColor color = origColor
if curr.nth { if curr.nth {
color = color.WithAttr((attrNth &^ tui.AttrRegular).Merge(nthOverlay)) color = color.WithAttr(attrNth &^ tui.AttrRegular)
} }
} else { } else {
color = origColor.MergeNonDefault(color) color = origColor.MergeNonDefault(color)
@@ -266,7 +253,7 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
ansi := itemColors[curr.index] ansi := itemColors[curr.index]
base := colBase base := colBase
if curr.nth { if curr.nth {
base = nthBase base = base.WithAttr(attrNth)
} }
if hidden { if hidden {
base = base.WithFg(theme.Nomatch) base = base.WithFg(theme.Nomatch)
@@ -278,7 +265,7 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
match: false, match: false,
url: ansi.color.url}) url: ansi.color.url})
} else { } else {
color := nthBase color := colBase.WithAttr(attrNth)
if hidden { if hidden {
color = color.WithFg(theme.Nomatch) color = color.WithFg(theme.Nomatch)
} }
@@ -301,20 +288,21 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
return colors return colors
} }
func compareOffsets(a, b Offset) int { // ByOrder is for sorting substring offsets
if a[0] < b[0] { type ByOrder []Offset
return -1
} func (a ByOrder) Len() int {
if a[0] > b[0] { return len(a)
return 1 }
}
if a[1] < b[1] { func (a ByOrder) Swap(i, j int) {
return -1 a[i], a[j] = a[j], a[i]
} }
if a[1] > b[1] {
return 1 func (a ByOrder) Less(i, j int) bool {
} ioff := a[i]
return 0 joff := a[j]
return (ioff[0] < joff[0]) || (ioff[0] == joff[0]) && (ioff[1] <= joff[1])
} }
// ByRelevance is for sorting Items // ByRelevance is for sorting Items
@@ -346,79 +334,3 @@ func (a ByRelevanceTac) Swap(i, j int) {
func (a ByRelevanceTac) Less(i, j int) bool { func (a ByRelevanceTac) Less(i, j int) bool {
return compareRanks(a[i], a[j], true) return compareRanks(a[i], a[j], true)
} }
// radixSortResults sorts Results by their points key using LSD radix sort.
// O(n) time complexity vs O(n log n) for comparison sort.
// The sort is stable, so equal-key items maintain original (item-index) order.
// For tac mode, runs of equal keys are reversed after sorting.
func radixSortResults(a []Result, tac bool, scratch []Result) []Result {
n := len(a)
if n < 128 {
if tac {
sort.Sort(ByRelevanceTac(a))
} else {
sort.Sort(ByRelevance(a))
}
return scratch[:0]
}
if cap(scratch) < n {
scratch = make([]Result, n)
}
buf := scratch[:n]
src, dst := a, buf
scattered := 0
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 -5
View File
@@ -1,4 +1,4 @@
//go:build !386 && !amd64 && !arm64 //go:build !386 && !amd64
package fzf package fzf
@@ -14,7 +14,3 @@ func compareRanks(irank Result, jrank Result, tac bool) bool {
} }
return (irank.item.Index() <= jrank.item.Index()) != tac return (irank.item.Index() <= jrank.item.Index()) != tac
} }
func sortKey(r *Result) uint64 {
return uint64(r.points[0]) | uint64(r.points[1])<<16 | uint64(r.points[2])<<32 | uint64(r.points[3])<<48
}
+7 -97
View File
@@ -2,8 +2,6 @@ package fzf
import ( import (
"math" "math"
"math/rand"
"slices"
"sort" "sort"
"testing" "testing"
@@ -20,7 +18,7 @@ func TestOffsetSort(t *testing.T) {
offsets := []Offset{ offsets := []Offset{
{3, 5}, {2, 7}, {3, 5}, {2, 7},
{1, 3}, {2, 9}} {1, 3}, {2, 9}}
slices.SortFunc(offsets, compareOffsets) sort.Sort(ByOrder(offsets))
if offsets[0][0] != 1 || offsets[0][1] != 3 || if offsets[0][0] != 1 || offsets[0][1] != 3 ||
offsets[1][0] != 2 || offsets[1][1] != 7 || offsets[1][0] != 2 || offsets[1][1] != 7 ||
@@ -126,14 +124,14 @@ func TestColorOffset(t *testing.T) {
item := Result{ item := Result{
item: &Item{ item: &Item{
colors: &[]ansiOffset{ colors: &[]ansiOffset{
{[2]int32{0, 20}, ansiState{1, 5, -1, 0, -1, nil}}, {[2]int32{0, 20}, ansiState{1, 5, 0, -1, nil}},
{[2]int32{22, 27}, ansiState{2, 6, -1, tui.Bold, -1, nil}}, {[2]int32{22, 27}, ansiState{2, 6, tui.Bold, -1, nil}},
{[2]int32{30, 32}, ansiState{3, 7, -1, 0, -1, nil}}, {[2]int32{30, 32}, ansiState{3, 7, 0, -1, nil}},
{[2]int32{33, 40}, ansiState{4, 8, -1, tui.Bold, -1, nil}}}}} {[2]int32{33, 40}, ansiState{4, 8, tui.Bold, -1, nil}}}}}
colBase := tui.NewColorPair(89, 189, tui.AttrUndefined) colBase := tui.NewColorPair(89, 189, tui.AttrUndefined)
colMatch := tui.NewColorPair(99, 199, tui.AttrUndefined) colMatch := tui.NewColorPair(99, 199, tui.AttrUndefined)
colors := item.colorOffsets(offsets, nil, tui.Dark256, colBase, colMatch, tui.AttrUndefined, 0, false) colors := item.colorOffsets(offsets, nil, tui.Dark256, colBase, colMatch, tui.AttrUndefined, false)
assert := func(idx int, b int32, e int32, c tui.ColorPair) { assert := func(idx int, b int32, e int32, c tui.ColorPair) {
o := colors[idx] o := colors[idx]
if o.offset[0] != b || o.offset[1] != e || o.color != c { if o.offset[0] != b || o.offset[1] != e || o.color != c {
@@ -160,7 +158,7 @@ func TestColorOffset(t *testing.T) {
nthOffsets := []Offset{{37, 39}, {42, 45}} nthOffsets := []Offset{{37, 39}, {42, 45}}
for _, attr := range []tui.Attr{tui.AttrRegular, tui.StrikeThrough} { for _, attr := range []tui.Attr{tui.AttrRegular, tui.StrikeThrough} {
colors = item.colorOffsets(offsets, nthOffsets, tui.Dark256, colRegular, colUnderline, attr, 0, false) colors = item.colorOffsets(offsets, nthOffsets, tui.Dark256, colRegular, colUnderline, attr, false)
// [{[0 5] {1 5 0}} {[5 15] {1 5 8}} {[15 20] {1 5 0}} // [{[0 5] {1 5 0}} {[5 15] {1 5 8}} {[15 20] {1 5 0}}
// {[22 25] {2 6 1}} {[25 27] {2 6 9}} {[27 30] {-1 -1 8}} // {[22 25] {2 6 1}} {[25 27] {2 6 9}} {[27 30] {-1 -1 8}}
@@ -183,92 +181,4 @@ func TestColorOffset(t *testing.T) {
assert(10, 37, 39, tui.NewColorPair(4, 8, expected)) assert(10, 37, 39, tui.NewColorPair(4, 8, expected))
assert(11, 39, 40, tui.NewColorPair(4, 8, tui.Bold)) assert(11, 39, 40, tui.NewColorPair(4, 8, tui.Bold))
} }
// Test nthOverlay: simulates nth:regular with current-fg:underline
// The overlay (underline) should survive even though nth:regular clears attrs.
// Precedence: fg < nth < current-fg
colors = item.colorOffsets(offsets, nthOffsets, tui.Dark256, colRegular, colUnderline, tui.AttrRegular, tui.Underline, false)
// nth regions should have Underline (from overlay), not cleared by AttrRegular
// Non-nth regions keep colBase attrs (AttrUndefined)
assert(0, 0, 5, tui.NewColorPair(1, 5, tui.AttrUndefined))
assert(1, 5, 15, tui.NewColorPair(1, 5, tui.Underline))
assert(2, 15, 20, tui.NewColorPair(1, 5, tui.AttrUndefined))
assert(3, 22, 25, tui.NewColorPair(2, 6, tui.Bold))
assert(4, 25, 27, tui.NewColorPair(2, 6, tui.Bold|tui.Underline))
assert(5, 27, 30, colUnderline)
assert(6, 30, 32, tui.NewColorPair(3, 7, tui.Underline))
assert(7, 32, 33, colUnderline)
assert(8, 33, 35, tui.NewColorPair(4, 8, tui.Bold|tui.Underline))
assert(9, 35, 37, tui.NewColorPair(4, 8, tui.Bold))
// nth region within ANSI bold: AttrRegular clears, overlay adds Underline back
assert(10, 37, 39, tui.NewColorPair(4, 8, tui.Bold|tui.Underline))
assert(11, 39, 40, tui.NewColorPair(4, 8, tui.Bold))
// Test nthOverlay with additive attrs: nth:strikethrough with selected-fg:bold
colors = item.colorOffsets(offsets, nthOffsets, tui.Dark256, colRegular, colUnderline, tui.StrikeThrough, tui.Bold, false)
// Non-nth entries unchanged from overlay=0 case
assert(0, 0, 5, tui.NewColorPair(1, 5, tui.AttrUndefined))
assert(5, 27, 30, colUnderline) // match only, no nth
assert(7, 32, 33, colUnderline) // match only, no nth
// nth region within ANSI bold: StrikeThrough|Bold merged with ANSI Bold
assert(10, 37, 39, tui.NewColorPair(4, 8, tui.Bold|tui.StrikeThrough))
}
func TestRadixSortResults(t *testing.T) {
sortCriteria = []criterion{byScore, byLength}
rng := rand.New(rand.NewSource(42))
for _, n := range []int{128, 256, 500, 1000} {
for _, tac := range []bool{false, true} {
// Build items with random points and indices
items := make([]*Item, n)
for i := range items {
items[i] = &Item{text: util.Chars{Index: int32(i)}}
}
results := make([]Result, n)
for i := range results {
results[i] = Result{
item: items[i],
points: [4]uint16{
uint16(rng.Intn(256)),
uint16(rng.Intn(256)),
uint16(rng.Intn(256)),
uint16(rng.Intn(256)),
},
}
}
// Make some duplicates to test stability
for i := 0; i < n/4; i++ {
j := rng.Intn(n)
k := rng.Intn(n)
results[j].points = results[k].points
}
// Copy for reference sort
expected := make([]Result, n)
copy(expected, results)
if tac {
sort.Sort(ByRelevanceTac(expected))
} else {
sort.Sort(ByRelevance(expected))
}
// Radix sort
var scratch []Result
scratch = radixSortResults(results, tac, scratch)
for i := range results {
if results[i] != expected[i] {
t.Errorf("n=%d tac=%v: mismatch at index %d: got item %d, want item %d",
n, tac, i, results[i].item.Index(), expected[i].item.Index())
break
}
}
}
}
} }
+1 -5
View File
@@ -1,4 +1,4 @@
//go:build 386 || amd64 || arm64 //go:build 386 || amd64
package fzf package fzf
@@ -14,7 +14,3 @@ func compareRanks(irank Result, jrank Result, tac bool) bool {
} }
return (irank.item.Index() <= jrank.item.Index()) != tac return (irank.item.Index() <= jrank.item.Index()) != tac
} }
func sortKey(r *Result) uint64 {
return *(*uint64)(unsafe.Pointer(&r.points[0]))
}
+22 -29
View File
@@ -122,12 +122,13 @@ func startHttpServer(address listenAddress, actionChannel chan []*action, getHan
} }
} }
server := httpServer{
apiKey: []byte(apiKey),
actionChannel: actionChannel,
getHandler: getHandler,
}
go func() { go func() {
server := httpServer{
apiKey: []byte(apiKey),
actionChannel: actionChannel,
getHandler: getHandler,
}
for { for {
conn, err := listener.Accept() conn, err := listener.Accept()
if err != nil { if err != nil {
@@ -153,7 +154,7 @@ func startHttpServer(address listenAddress, actionChannel chan []*action, getHan
func (server *httpServer) handleHttpRequest(conn net.Conn) string { func (server *httpServer) handleHttpRequest(conn net.Conn) string {
contentLength := 0 contentLength := 0
apiKey := "" apiKey := ""
var bodyBuilder strings.Builder body := ""
answer := func(code string, message string) string { answer := func(code string, message string) string {
message += "\n" message += "\n"
return code + fmt.Sprintf("Content-Length: %d%s", len(message), crlf+crlf+message) return code + fmt.Sprintf("Content-Length: %d%s", len(message), crlf+crlf+message)
@@ -175,29 +176,30 @@ func (server *httpServer) handleHttpRequest(conn net.Conn) string {
token := data[:found+len(crlf)] token := data[:found+len(crlf)]
return len(token), token, nil return len(token), token, nil
} }
if atEOF || bodyBuilder.Len()+len(data) >= contentLength { if atEOF || len(body)+len(data) >= contentLength {
return 0, data, bufio.ErrFinalToken return 0, data, bufio.ErrFinalToken
} }
return 0, nil, nil return 0, nil, nil
}) })
section := 0 section := 0
var getMatch []string
Loop:
for scanner.Scan() { for scanner.Scan() {
text := scanner.Text() text := scanner.Text()
switch section { switch section {
case 0: // Request line case 0:
getMatch = getRegex.FindStringSubmatch(text) getMatch := getRegex.FindStringSubmatch(text)
if len(getMatch) == 0 && !strings.HasPrefix(text, "POST / HTTP") { 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") {
return bad("invalid request method") return bad("invalid request method")
} }
section++ section++
case 1: // Request headers case 1:
if text == crlf { // End of headers if text == crlf {
if len(getMatch) > 0 {
break Loop
}
if contentLength == 0 { if contentLength == 0 {
return bad("content-length header missing") return bad("content-length header missing")
} }
@@ -217,8 +219,8 @@ Loop:
apiKey = strings.TrimSpace(pair[1]) apiKey = strings.TrimSpace(pair[1])
} }
} }
case 2: // Request body case 2:
bodyBuilder.WriteString(text) body += text
} }
} }
@@ -226,21 +228,12 @@ Loop:
return unauthorized("invalid api key") 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 { if len(body) < contentLength {
return bad("incomplete request") return bad("incomplete request")
} }
body = body[:contentLength] body = body[:contentLength]
actions, err := parseSingleActionList(strings.Trim(string(body), "\r\n"), false) actions, err := parseSingleActionList(strings.Trim(string(body), "\r\n"))
if err != nil { if err != nil {
return bad(err.Error()) return bad(err.Error())
} }
+629 -1825
View File
File diff suppressed because it is too large Load Diff
-69
View File
@@ -699,72 +699,3 @@ 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)
}
}
+23 -198
View File
@@ -1,214 +1,39 @@
package fzf package fzf
import ( import (
"fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"strings"
"github.com/junegunn/fzf/src/tui" "github.com/junegunn/fzf/src/tui"
) )
// Returns the size of the current window if the tmux server supports
// floating panes (tmux 3.7 or above)
func tmuxFloatingPaneInfo() (int, int, bool) {
target := os.Getenv("TMUX_PANE")
if target == "" {
return 0, 0, false
}
// A single invocation for both checks. Cannot rely on the exit status;
// tmux versions before 3.7 exit normally with empty output for an
// unknown command name, so check the output instead.
out, err := exec.Command("tmux", "display-message", "-p", "-t", target,
"#{window_width} #{window_height}", ";", "list-commands", "new-pane").Output()
if err != nil || !strings.Contains(string(out), "new-pane") {
return 0, 0, false
}
var width, height int
if _, err := fmt.Sscanf(string(out), "%d %d", &width, &height); err != nil {
return 0, 0, false
}
// The window is too small to fit a floating pane of the minimum size
if width < 3 || height < 3 {
return 0, 0, false
}
return width, height, true
}
// A lone ';' argument is a command separator to tmux, aborting the whole
// command at parse time
func escapeTmuxSeparator(str string) string {
if str == ";" {
return `\;`
}
return str
}
// Escape a string for use as the pane title; select-pane -T expands format
// expressions denoted by '#', but not time conversion specifiers
func escapeTmuxTitle(str string) string {
return escapeTmuxSeparator(strings.ReplaceAll(str, "#", "##"))
}
// Convert sizeSpec to the number of cells, clamped between the minimum
// footprint of 3, including the border, and the window size
func tmuxDim(spec sizeSpec, window int) int {
dim := int(spec.size)
if spec.percent {
dim = window * dim / 100
}
return max(3, min(dim, window))
}
func runTmuxFloatingPane(argStr string, dir string, windowWidth int, windowHeight int, opts *Options) (int, error) {
// Unlike display-popup, the size of a floating pane does not account for
// the border around it, and the position is that of the content area. To
// stay consistent with popups, treat the requested size as the total
// footprint including the border.
width := tmuxDim(opts.Tmux.width, windowWidth)
height := tmuxDim(opts.Tmux.height, windowHeight)
x := (windowWidth-width)/2 + 1
y := (windowHeight-height)/2 + 1
switch opts.Tmux.position {
case posUp:
y = 1
case posDown:
y = windowHeight - height + 1
case posLeft:
x = 1
case posRight:
x = windowWidth - width + 1
}
return runProxy(argStr, func(temp string, needBash bool) (*exec.Cmd, error) {
sh, err := sh(needBash)
if err != nil {
return nil, err
}
// Unlike display-popup, new-pane does not block until the command
// finishes, and it does not propagate the exit status. So we block on
// a wait-for channel that the pane signals on completion, and pass
// the exit status through a temporary file. A watchdog process
// signals the same channel if the pane is closed abnormally
// (e.g. kill-pane), in which case the file is not written.
//
// has-session is the liveness check because it fails when the target
// pane is gone, while display-message succeeds even for a dead pane.
signal := escapeSingleQuote("fzf-" + filepath.Base(temp))
// Pre-create the exit status file so that another user on a shared
// TMPDIR cannot plant a file or a symbolic link at the predictable
// path while the pane is running
codeFile := temp + ".code"
f, err := os.OpenFile(codeFile, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return nil, err
}
f.Close()
code := escapeSingleQuote(codeFile)
// Pane options are set by the pane itself before running fzf, so
// that they are in place no matter how quickly the command exits.
// The target must be explicit; without it, the commands would
// resolve to the active pane of the session's current window,
// which is not necessarily the pane running them.
//
// The pane should always close on exit like a popup, even when
// remain-on-exit is on.
setup := `tmux set-option -p -t "$TMUX_PANE" remain-on-exit off 2> /dev/null; `
// Set --border-label as the title of the floating pane, and as its
// pane-border-format so that it is displayed on the border when
// pane-border-status is enabled. Without a label, the border text
// is cleared so that the default pane status content (e.g. the
// pane title) is not shown. pane-border-format is pane-scoped,
// but pane-border-status is a window option that only becomes
// pane-scoped in the next release of tmux, so it is left alone.
// https://github.com/tmux/tmux/commit/7a18fa281db3
// --border-label-pos is ignored.
// The label is left to fzf when it draws its own border with the
// label on it. '--border=none' is not the case; fzf would not
// display the label, but the native border of a floating pane
// cannot be removed, so display the label on it nonetheless.
format := ""
if opts.BorderLabel.label != "" &&
(opts.BorderShape == tui.BorderUndefined || opts.BorderShape == tui.BorderLine ||
opts.BorderShape == tui.BorderNone) {
// Strip ANSI sequences fzf would otherwise render itself
label, _, _ := extractColor(opts.BorderLabel.label, nil, nil)
if label != "" {
setup += fmt.Sprintf(`tmux select-pane -t "$TMUX_PANE" -T %s 2> /dev/null; `,
escapeSingleQuote(escapeTmuxTitle(label)))
// The title is displayed verbatim; substituted values are
// not expanded again
format = "#{pane_title}"
}
}
setup += fmt.Sprintf(`tmux set-option -p -t "$TMUX_PANE" pane-border-format %s 2> /dev/null; `,
escapeSingleQuote(format))
paneCmd := fmt.Sprintf("%s%s %s; echo $? > %s; tmux wait-for -S %s",
setup, escapeSingleQuote(sh), escapeSingleQuote(temp), code, signal)
// Unzoom the window first; creating a floating pane over a zoomed
// window crashes the tmux server on 3.7b, and newer versions of
// tmux unzoom the window anyway.
target := os.Getenv("TMUX_PANE")
newPane := fmt.Sprintf(
"tmux if -F -t %s '#{window_zoomed_flag}' %s ';' new-pane -P -F '#{pane_id}' -t %s -c %s -x %d -y %d -X %d -Y %d %s -c %s",
escapeSingleQuote(target), escapeSingleQuote("resize-pane -Z -t "+target),
escapeSingleQuote(target), escapeSingleQuote(dir), width-2, height-2, x, y,
escapeSingleQuote(sh), escapeSingleQuote(paneCmd))
// The pane is killed when the proxy process is interrupted or hung up,
// like a popup dying with its client. wait-for runs in the background
// and is awaited with the interruptible wait builtin so that the trap
// can fire while blocked. The trap is installed before creating the
// pane; a signal received during creation is deferred until the
// command substitution completes, and the pane is killed right after.
// An interrupted wait does not reap the waiter, so it is killed
// along with the watchdog.
script := fmt.Sprintf(`trap '[ -n "$id" ] && tmux kill-pane -t "$id" 2> /dev/null' INT TERM HUP
id=$(%s) || { status=$?; rm -f %s; exit "$status"; }
{ while tmux has-session -t "$id" 2> /dev/null; do sleep 1; done; tmux wait-for -S %s; } &
watchdog=$!
tmux wait-for %s &
waiter=$!
wait "$waiter"
kill "$watchdog" "$waiter" 2> /dev/null
wait 2> /dev/null
if [ -s %s ]; then code=$(cat %s); else code=130; fi
rm -f %s
exit "$code"`, newPane, code, signal, signal, code, code, code)
return exec.Command(sh, "-c", script), nil
}, opts, true)
}
// Whether to use the multiplexer's native border for the floating pane. Its
// native border is the handle that makes the pane movable and resizable with
// the mouse, so it is the default; 'border-native' forces it. It is not used
// when a border style is explicitly specified with --border, so that the
// fzf-drawn border is the only one shown. 'none' and 'line' are treated as no
// border; fzf draws no box for either, and 'line' only makes sense with
// --height.
func nativeBorder(opts *Options) bool {
return opts.Tmux.border || opts.BorderShape == tui.BorderUndefined ||
opts.BorderShape == tui.BorderLine || opts.BorderShape == tui.BorderNone
}
func runTmux(args []string, opts *Options) (int, error) { func runTmux(args []string, opts *Options) (int, error) {
// On tmux 3.7 or above, fzf runs in a floating pane instead of a popup. // Prepare arguments
// When the native border is not used (an explicit --border style), a fzf, rest := args[0], args[1:]
// popup is used instead so that the fzf-drawn border is the only border args = []string{"--bind=ctrl-z:ignore"}
// shown; the native border of a tmux floating pane cannot be removed. if !opts.Tmux.border && (opts.BorderShape == tui.BorderUndefined || opts.BorderShape == tui.BorderLine) {
if nativeBorder(opts) { // We append --border option at the end, because `--style=full:STYLE`
if windowWidth, windowHeight, ok := tmuxFloatingPaneInfo(); ok { // may have changed the default border style.
opts.Tmux.border = true if tui.DefaultBorderShape == tui.BorderRounded {
argStr, dir := popupArgStr(args, opts) rest = append(rest, "--border=rounded")
return runTmuxFloatingPane(argStr, dir, windowWidth, windowHeight, opts) } 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`
argStr, dir := popupArgStr(args, opts) // Get current directory
dir, err := os.Getwd()
if err != nil {
dir = "."
}
// Set tmux options for popup placement // Set tmux options for popup placement
// C Both The centre of the terminal // C Both The centre of the terminal
-39
View File
@@ -1,39 +0,0 @@
package fzf
import "testing"
func TestEscapeTmuxTitle(t *testing.T) {
for _, tc := range []struct {
given string
expected string
}{
{"", ""},
{" fzf ", " fzf "},
{"#", "##"},
{"##", "####"},
{" C# notes #S ", " C## notes ##S "},
{"100%", "100%"},
{";", `\;`},
{"; rm", "; rm"},
{" ; ", " ; "},
} {
if actual := escapeTmuxTitle(tc.given); actual != tc.expected {
t.Errorf("expected %q, got %q", tc.expected, actual)
}
}
}
func TestEscapeTmuxTitleSeparator(t *testing.T) {
for _, tc := range []struct {
given string
expected string
}{
{"#;", "##;"},
{";#", ";##"},
{";;", ";;"},
} {
if actual := escapeTmuxTitle(tc.given); actual != tc.expected {
t.Errorf("expected %q, got %q", tc.expected, actual)
}
}
}
+5 -7
View File
@@ -161,7 +161,7 @@ func awkTokenizer(input string) ([]string, int) {
end := 0 end := 0
for idx := 0; idx < len(input); idx++ { for idx := 0; idx < len(input); idx++ {
r := input[idx] r := input[idx]
white := r == 9 || r == 32 || r == 10 white := r == 9 || r == 32
switch state { switch state {
case awkNil: case awkNil:
if white { if white {
@@ -218,12 +218,11 @@ func Tokenize(text string, delimiter Delimiter) []Token {
return withPrefixLengths(tokens, 0) return withPrefixLengths(tokens, 0)
} }
// StripLastDelimiter removes the trailing delimiter // StripLastDelimiter removes the trailing delimiter and whitespaces
func StripLastDelimiter(str string, delimiter Delimiter) string { func StripLastDelimiter(str string, delimiter Delimiter) string {
if delimiter.str != nil { if delimiter.str != nil {
return strings.TrimSuffix(str, *delimiter.str) str = strings.TrimSuffix(str, *delimiter.str)
} } else if delimiter.regex != nil {
if delimiter.regex != nil {
locs := delimiter.regex.FindAllStringIndex(str, -1) locs := delimiter.regex.FindAllStringIndex(str, -1)
if len(locs) > 0 { if len(locs) > 0 {
lastLoc := locs[len(locs)-1] lastLoc := locs[len(locs)-1]
@@ -231,7 +230,6 @@ func StripLastDelimiter(str string, delimiter Delimiter) string {
str = str[:lastLoc[0]] str = str[:lastLoc[0]]
} }
} }
return str
} }
return strings.TrimRightFunc(str, unicode.IsSpace) return strings.TrimRightFunc(str, unicode.IsSpace)
} }
@@ -304,7 +302,7 @@ func Transform(tokens []Token, withNth []Range) []Token {
end += numTokens + 1 end += numTokens + 1
} }
} }
minIdx = max(0, begin-1) minIdx = util.Max(0, begin-1)
for idx := begin; idx <= end; idx++ { for idx := begin; idx <= end; idx++ {
if idx >= 1 && idx <= numTokens { if idx >= 1 && idx <= numTokens {
parts = append(parts, tokens[idx-1].text) parts = append(parts, tokens[idx-1].text)
+5 -5
View File
@@ -56,9 +56,9 @@ func TestParseRange(t *testing.T) {
func TestTokenize(t *testing.T) { func TestTokenize(t *testing.T) {
// AWK-style // AWK-style
input := " abc: \n\t def: ghi " input := " abc: def: ghi "
tokens := Tokenize(input, Delimiter{}) tokens := Tokenize(input, Delimiter{})
if tokens[0].text.ToString() != "abc: \n\t " || tokens[0].prefixLength != 2 { if tokens[0].text.ToString() != "abc: " || tokens[0].prefixLength != 2 {
t.Errorf("%s", tokens) t.Errorf("%s", tokens)
} }
@@ -71,9 +71,9 @@ func TestTokenize(t *testing.T) {
// With delimiter regex // With delimiter regex
tokens = Tokenize(input, delimiterRegexp("\\s+")) tokens = Tokenize(input, delimiterRegexp("\\s+"))
if tokens[0].text.ToString() != " " || tokens[0].prefixLength != 0 || if tokens[0].text.ToString() != " " || tokens[0].prefixLength != 0 ||
tokens[1].text.ToString() != "abc: \n\t " || tokens[1].prefixLength != 2 || tokens[1].text.ToString() != "abc: " || tokens[1].prefixLength != 2 ||
tokens[2].text.ToString() != "def: " || tokens[2].prefixLength != 10 || tokens[2].text.ToString() != "def: " || tokens[2].prefixLength != 8 ||
tokens[3].text.ToString() != "ghi " || tokens[3].prefixLength != 16 { tokens[3].text.ToString() != "ghi " || tokens[3].prefixLength != 14 {
t.Errorf("%s", tokens) t.Errorf("%s", tokens)
} }
} }
+5 -5
View File
@@ -34,11 +34,11 @@ func (r *FullscreenRenderer) ShowCursor() {}
func (r *FullscreenRenderer) Refresh() {} func (r *FullscreenRenderer) Refresh() {}
func (r *FullscreenRenderer) Close() {} func (r *FullscreenRenderer) Close() {}
func (r *FullscreenRenderer) Size() TermSize { return TermSize{} } func (r *FullscreenRenderer) Size() TermSize { return TermSize{} }
func (r *FullscreenRenderer) Top() int { return 0 }
func (r *FullscreenRenderer) MaxX() int { return 0 } func (r *FullscreenRenderer) GetChar() Event { return Event{} }
func (r *FullscreenRenderer) MaxY() int { return 0 } func (r *FullscreenRenderer) Top() int { return 0 }
func (r *FullscreenRenderer) GetChar(bool) Event { return Event{} } func (r *FullscreenRenderer) MaxX() int { return 0 }
func (r *FullscreenRenderer) CancelGetChar() {} func (r *FullscreenRenderer) MaxY() int { return 0 }
func (r *FullscreenRenderer) RefreshWindows(windows []Window) {} func (r *FullscreenRenderer) RefreshWindows(windows []Window) {}
+66 -69
View File
@@ -16,7 +16,7 @@ func _() {
_ = x[CtrlE-5] _ = x[CtrlE-5]
_ = x[CtrlF-6] _ = x[CtrlF-6]
_ = x[CtrlG-7] _ = x[CtrlG-7]
_ = x[CtrlH-8] _ = x[CtrlBackspace-8]
_ = x[Tab-9] _ = x[Tab-9]
_ = x[CtrlJ-10] _ = x[CtrlJ-10]
_ = x[CtrlK-11] _ = x[CtrlK-11]
@@ -99,77 +99,74 @@ func _() {
_ = x[CtrlRight-88] _ = x[CtrlRight-88]
_ = x[CtrlHome-89] _ = x[CtrlHome-89]
_ = x[CtrlEnd-90] _ = x[CtrlEnd-90]
_ = x[CtrlBackspace-91] _ = x[CtrlDelete-91]
_ = x[CtrlDelete-92] _ = x[CtrlPageUp-92]
_ = x[CtrlPageUp-93] _ = x[CtrlPageDown-93]
_ = x[CtrlPageDown-94] _ = x[Alt-94]
_ = x[Alt-95] _ = x[CtrlAlt-95]
_ = x[CtrlAlt-96] _ = x[CtrlAltUp-96]
_ = x[CtrlAltUp-97] _ = x[CtrlAltDown-97]
_ = x[CtrlAltDown-98] _ = x[CtrlAltLeft-98]
_ = x[CtrlAltLeft-99] _ = x[CtrlAltRight-99]
_ = x[CtrlAltRight-100] _ = x[CtrlAltHome-100]
_ = x[CtrlAltHome-101] _ = x[CtrlAltEnd-101]
_ = x[CtrlAltEnd-102] _ = x[CtrlAltBackspace-102]
_ = x[CtrlAltBackspace-103] _ = x[CtrlAltDelete-103]
_ = x[CtrlAltDelete-104] _ = x[CtrlAltPageUp-104]
_ = x[CtrlAltPageUp-105] _ = x[CtrlAltPageDown-105]
_ = x[CtrlAltPageDown-106] _ = x[CtrlShiftUp-106]
_ = x[CtrlShiftUp-107] _ = x[CtrlShiftDown-107]
_ = x[CtrlShiftDown-108] _ = x[CtrlShiftLeft-108]
_ = x[CtrlShiftLeft-109] _ = x[CtrlShiftRight-109]
_ = x[CtrlShiftRight-110] _ = x[CtrlShiftHome-110]
_ = x[CtrlShiftHome-111] _ = x[CtrlShiftEnd-111]
_ = x[CtrlShiftEnd-112] _ = x[CtrlShiftDelete-112]
_ = x[CtrlShiftDelete-113] _ = x[CtrlShiftPageUp-113]
_ = x[CtrlShiftPageUp-114] _ = x[CtrlShiftPageDown-114]
_ = x[CtrlShiftPageDown-115] _ = x[CtrlAltShiftUp-115]
_ = x[CtrlAltShiftUp-116] _ = x[CtrlAltShiftDown-116]
_ = x[CtrlAltShiftDown-117] _ = x[CtrlAltShiftLeft-117]
_ = x[CtrlAltShiftLeft-118] _ = x[CtrlAltShiftRight-118]
_ = x[CtrlAltShiftRight-119] _ = x[CtrlAltShiftHome-119]
_ = x[CtrlAltShiftHome-120] _ = x[CtrlAltShiftEnd-120]
_ = x[CtrlAltShiftEnd-121] _ = x[CtrlAltShiftDelete-121]
_ = x[CtrlAltShiftDelete-122] _ = x[CtrlAltShiftPageUp-122]
_ = x[CtrlAltShiftPageUp-123] _ = x[CtrlAltShiftPageDown-123]
_ = x[CtrlAltShiftPageDown-124] _ = x[Invalid-124]
_ = x[Mouse-125] _ = x[Fatal-125]
_ = x[DoubleClick-126] _ = x[BracketedPasteBegin-126]
_ = x[LeftClick-127] _ = x[BracketedPasteEnd-127]
_ = x[RightClick-128] _ = x[Mouse-128]
_ = x[SLeftClick-129] _ = x[DoubleClick-129]
_ = x[SRightClick-130] _ = x[LeftClick-130]
_ = x[ScrollUp-131] _ = x[RightClick-131]
_ = x[ScrollDown-132] _ = x[SLeftClick-132]
_ = x[SScrollUp-133] _ = x[SRightClick-133]
_ = x[SScrollDown-134] _ = x[ScrollUp-134]
_ = x[PreviewScrollUp-135] _ = x[ScrollDown-135]
_ = x[PreviewScrollDown-136] _ = x[SScrollUp-136]
_ = x[Invalid-137] _ = x[SScrollDown-137]
_ = x[Fatal-138] _ = x[PreviewScrollUp-138]
_ = x[BracketedPasteBegin-139] _ = x[PreviewScrollDown-139]
_ = x[BracketedPasteEnd-140] _ = x[Resize-140]
_ = x[Resize-141] _ = x[Change-141]
_ = x[Change-142] _ = x[BackwardEOF-142]
_ = x[BackwardEOF-143] _ = x[Start-143]
_ = x[Start-144] _ = x[Load-144]
_ = x[Load-145] _ = x[Focus-145]
_ = x[Focus-146] _ = x[One-146]
_ = x[One-147] _ = x[Zero-147]
_ = x[Zero-148] _ = x[Result-148]
_ = x[Result-149] _ = x[Jump-149]
_ = x[Jump-150] _ = x[JumpCancel-150]
_ = x[JumpCancel-151] _ = x[ClickHeader-151]
_ = x[ClickHeader-152] _ = x[ClickFooter-152]
_ = x[ClickFooter-153] _ = x[Multi-153]
_ = x[Multi-154]
_ = x[Every-155]
_ = x[ResultFinal-156]
} }
const _EventType_name = "RuneCtrlACtrlBCtrlCCtrlDCtrlECtrlFCtrlGCtrlHTabCtrlJCtrlKCtrlLEnterCtrlNCtrlOCtrlPCtrlQCtrlRCtrlSCtrlTCtrlUCtrlVCtrlWCtrlXCtrlYCtrlZEscCtrlSpaceCtrlBackSlashCtrlRightBracketCtrlCaretCtrlSlashShiftTabBackspaceDeletePageUpPageDownUpDownLeftRightHomeEndInsertShiftUpShiftDownShiftLeftShiftRightShiftDeleteShiftHomeShiftEndShiftPageUpShiftPageDownF1F2F3F4F5F6F7F8F9F10F11F12AltBackspaceAltUpAltDownAltLeftAltRightAltDeleteAltHomeAltEndAltPageUpAltPageDownAltShiftUpAltShiftDownAltShiftLeftAltShiftRightAltShiftDeleteAltShiftHomeAltShiftEndAltShiftPageUpAltShiftPageDownCtrlUpCtrlDownCtrlLeftCtrlRightCtrlHomeCtrlEndCtrlBackspaceCtrlDeleteCtrlPageUpCtrlPageDownAltCtrlAltCtrlAltUpCtrlAltDownCtrlAltLeftCtrlAltRightCtrlAltHomeCtrlAltEndCtrlAltBackspaceCtrlAltDeleteCtrlAltPageUpCtrlAltPageDownCtrlShiftUpCtrlShiftDownCtrlShiftLeftCtrlShiftRightCtrlShiftHomeCtrlShiftEndCtrlShiftDeleteCtrlShiftPageUpCtrlShiftPageDownCtrlAltShiftUpCtrlAltShiftDownCtrlAltShiftLeftCtrlAltShiftRightCtrlAltShiftHomeCtrlAltShiftEndCtrlAltShiftDeleteCtrlAltShiftPageUpCtrlAltShiftPageDownMouseDoubleClickLeftClickRightClickSLeftClickSRightClickScrollUpScrollDownSScrollUpSScrollDownPreviewScrollUpPreviewScrollDownInvalidFatalBracketedPasteBeginBracketedPasteEndResizeChangeBackwardEOFStartLoadFocusOneZeroResultJumpJumpCancelClickHeaderClickFooterMultiEveryResultFinal" const _EventType_name = "RuneCtrlACtrlBCtrlCCtrlDCtrlECtrlFCtrlGCtrlBackspaceTabCtrlJCtrlKCtrlLEnterCtrlNCtrlOCtrlPCtrlQCtrlRCtrlSCtrlTCtrlUCtrlVCtrlWCtrlXCtrlYCtrlZEscCtrlSpaceCtrlBackSlashCtrlRightBracketCtrlCaretCtrlSlashShiftTabBackspaceDeletePageUpPageDownUpDownLeftRightHomeEndInsertShiftUpShiftDownShiftLeftShiftRightShiftDeleteShiftHomeShiftEndShiftPageUpShiftPageDownF1F2F3F4F5F6F7F8F9F10F11F12AltBackspaceAltUpAltDownAltLeftAltRightAltDeleteAltHomeAltEndAltPageUpAltPageDownAltShiftUpAltShiftDownAltShiftLeftAltShiftRightAltShiftDeleteAltShiftHomeAltShiftEndAltShiftPageUpAltShiftPageDownCtrlUpCtrlDownCtrlLeftCtrlRightCtrlHomeCtrlEndCtrlDeleteCtrlPageUpCtrlPageDownAltCtrlAltCtrlAltUpCtrlAltDownCtrlAltLeftCtrlAltRightCtrlAltHomeCtrlAltEndCtrlAltBackspaceCtrlAltDeleteCtrlAltPageUpCtrlAltPageDownCtrlShiftUpCtrlShiftDownCtrlShiftLeftCtrlShiftRightCtrlShiftHomeCtrlShiftEndCtrlShiftDeleteCtrlShiftPageUpCtrlShiftPageDownCtrlAltShiftUpCtrlAltShiftDownCtrlAltShiftLeftCtrlAltShiftRightCtrlAltShiftHomeCtrlAltShiftEndCtrlAltShiftDeleteCtrlAltShiftPageUpCtrlAltShiftPageDownInvalidFatalBracketedPasteBeginBracketedPasteEndMouseDoubleClickLeftClickRightClickSLeftClickSRightClickScrollUpScrollDownSScrollUpSScrollDownPreviewScrollUpPreviewScrollDownResizeChangeBackwardEOFStartLoadFocusOneZeroResultJumpJumpCancelClickHeaderClickFooterMulti"
var _EventType_index = [...]uint16{0, 4, 9, 14, 19, 24, 29, 34, 39, 44, 47, 52, 57, 62, 67, 72, 77, 82, 87, 92, 97, 102, 107, 112, 117, 122, 127, 132, 135, 144, 157, 173, 182, 191, 199, 208, 214, 220, 228, 230, 234, 238, 243, 247, 250, 256, 263, 272, 281, 291, 302, 311, 319, 330, 343, 345, 347, 349, 351, 353, 355, 357, 359, 361, 364, 367, 370, 382, 387, 394, 401, 409, 418, 425, 431, 440, 451, 461, 473, 485, 498, 512, 524, 535, 549, 565, 571, 579, 587, 596, 604, 611, 624, 634, 644, 656, 659, 666, 675, 686, 697, 709, 720, 730, 746, 759, 772, 787, 798, 811, 824, 838, 851, 863, 878, 893, 910, 924, 940, 956, 973, 989, 1004, 1022, 1040, 1060, 1065, 1076, 1085, 1095, 1105, 1116, 1124, 1134, 1143, 1154, 1169, 1186, 1193, 1198, 1217, 1234, 1240, 1246, 1257, 1262, 1266, 1271, 1274, 1278, 1284, 1288, 1298, 1309, 1320, 1325, 1330, 1341} var _EventType_index = [...]uint16{0, 4, 9, 14, 19, 24, 29, 34, 39, 52, 55, 60, 65, 70, 75, 80, 85, 90, 95, 100, 105, 110, 115, 120, 125, 130, 135, 140, 143, 152, 165, 181, 190, 199, 207, 216, 222, 228, 236, 238, 242, 246, 251, 255, 258, 264, 271, 280, 289, 299, 310, 319, 327, 338, 351, 353, 355, 357, 359, 361, 363, 365, 367, 369, 372, 375, 378, 390, 395, 402, 409, 417, 426, 433, 439, 448, 459, 469, 481, 493, 506, 520, 532, 543, 557, 573, 579, 587, 595, 604, 612, 619, 629, 639, 651, 654, 661, 670, 681, 692, 704, 715, 725, 741, 754, 767, 782, 793, 806, 819, 833, 846, 858, 873, 888, 905, 919, 935, 951, 968, 984, 999, 1017, 1035, 1055, 1062, 1067, 1086, 1103, 1108, 1119, 1128, 1138, 1148, 1159, 1167, 1177, 1186, 1197, 1212, 1229, 1235, 1241, 1252, 1257, 1261, 1266, 1269, 1273, 1279, 1283, 1293, 1304, 1315, 1320}
func (i EventType) String() string { func (i EventType) String() string {
if i < 0 || i >= EventType(len(_EventType_index)-1) { if i < 0 || i >= EventType(len(_EventType_index)-1) {
+189 -249
View File
@@ -13,6 +13,7 @@ import (
"unicode/utf8" "unicode/utf8"
"github.com/junegunn/fzf/src/util" "github.com/junegunn/fzf/src/util"
"github.com/rivo/uniseg"
"golang.org/x/term" "golang.org/x/term"
) )
@@ -25,7 +26,6 @@ const (
escPollInterval = 5 escPollInterval = 5
offsetPollTries = 10 offsetPollTries = 10
maxInputBuffer = 1024 * 1024 maxInputBuffer = 1024 * 1024
maxSelectTries = 100
) )
const DefaultTtyDevice string = "/dev/tty" const DefaultTtyDevice string = "/dev/tty"
@@ -49,26 +49,13 @@ const DIM string = "\x1b[2m"
const CR string = DIM + "␍" const CR string = DIM + "␍"
const LF 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) { func (r *LightRenderer) stderrInternal(str string, allowNLCR bool, resetCode string) {
bytes := []byte(str) bytes := []byte(str)
runes := []rune{} runes := []rune{}
for len(bytes) > 0 { for len(bytes) > 0 {
r, sz := utf8.DecodeRune(bytes) r, sz := utf8.DecodeRune(bytes)
nlcr := r == '\n' || r == '\r' nlcr := r == '\n' || r == '\r'
isC1 := r >= 0x80 && r <= 0x9F if r >= 32 || r == '\x1b' || nlcr {
if (r >= 32 && !isC1) || r == '\x1b' || nlcr {
if nlcr && !allowNLCR { if nlcr && !allowNLCR {
if r == '\r' { if r == '\r' {
runes = append(runes, []rune(CR+resetCode)...) runes = append(runes, []rune(CR+resetCode)...)
@@ -117,7 +104,6 @@ type LightRenderer struct {
clicks [][2]int clicks [][2]int
ttyin *os.File ttyin *os.File
ttyout *os.File ttyout *os.File
cancel func()
buffer []byte buffer []byte
origState *term.State origState *term.State
width int width int
@@ -132,9 +118,9 @@ type LightRenderer struct {
x int x int
maxHeightFunc func(int) int maxHeightFunc func(int) int
showCursor bool showCursor bool
mutex sync.Mutex
// Windows only // Windows only
mutex sync.Mutex
ttyinChannel chan byte ttyinChannel chan byte
inHandle uintptr inHandle uintptr
outHandle uintptr outHandle uintptr
@@ -207,6 +193,11 @@ func (r *LightRenderer) Init() error {
if r.fullscreen { if r.fullscreen {
r.smcup() r.smcup()
} else { } 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() y, x := r.findOffset()
r.mouse = r.mouse && y >= 0 r.mouse = r.mouse && y >= 0
// When --no-clear is used for repetitive relaunching, there is a small // When --no-clear is used for repetitive relaunching, there is a small
@@ -217,11 +208,6 @@ func (r *LightRenderer) Init() error {
r.upOneLine = true r.upOneLine = true
r.makeSpace() 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++ { for i := 1; i < r.MaxY(); i++ {
r.makeSpace() r.makeSpace()
} }
@@ -276,18 +262,16 @@ func getEnv(name string, defaultValue int) int {
return atoi(env, defaultValue) return atoi(env, defaultValue)
} }
func (r *LightRenderer) getBytes(cancellable bool) ([]byte, getCharResult, error) { func (r *LightRenderer) getBytes() ([]byte, error) {
return r.getBytesInternal(cancellable, r.buffer, false) bytes, err := r.getBytesInternal(r.buffer, false)
return bytes, err
} }
func (r *LightRenderer) getBytesInternal(cancellable bool, buffer []byte, nonblock bool) ([]byte, getCharResult, error) { func (r *LightRenderer) getBytesInternal(buffer []byte, nonblock bool) ([]byte, error) {
c, result := r.getch(cancellable, nonblock) c, ok := r.getch(nonblock)
if result == getCharCancelled { if !nonblock && !ok {
return buffer, getCharCancelled, nil
}
if !nonblock && !result.ok() {
r.Close() r.Close()
return nil, getCharError, errors.New("failed to read " + DefaultTtyDevice) return nil, errors.New("failed to read " + DefaultTtyDevice)
} }
retries := 0 retries := 0
@@ -298,8 +282,8 @@ func (r *LightRenderer) getBytesInternal(cancellable bool, buffer []byte, nonblo
pc := c pc := c
for { for {
c, result = r.getch(false, true) c, ok = r.getch(true)
if !result.ok() { if !ok {
if retries > 0 { if retries > 0 {
retries-- retries--
time.Sleep(escPollInterval * time.Millisecond) time.Sleep(escPollInterval * time.Millisecond)
@@ -318,24 +302,20 @@ func (r *LightRenderer) getBytesInternal(cancellable bool, buffer []byte, nonblo
// so terminate fzf immediately. // so terminate fzf immediately.
if len(buffer) > maxInputBuffer { if len(buffer) > maxInputBuffer {
r.Close() r.Close()
return nil, getCharError, fmt.Errorf("input buffer overflow (%d): %v", len(buffer), buffer) return nil, fmt.Errorf("input buffer overflow (%d): %v", len(buffer), buffer)
} }
} }
return buffer, getCharSuccess, nil return buffer, nil
} }
func (r *LightRenderer) GetChar(cancellable bool) Event { func (r *LightRenderer) GetChar() Event {
var err error var err error
var result getCharResult
if len(r.buffer) == 0 { if len(r.buffer) == 0 {
r.buffer, result, err = r.getBytes(cancellable) r.buffer, err = r.getBytes()
if err != nil { if err != nil {
return Event{Fatal, 0, nil} return Event{Fatal, 0, nil}
} }
if result == getCharCancelled {
return Event{Invalid, 0, nil}
}
} }
if len(r.buffer) == 0 { if len(r.buffer) == 0 {
return Event{Fatal, 0, nil} return Event{Fatal, 0, nil}
@@ -371,14 +351,9 @@ func (r *LightRenderer) GetChar(cancellable bool) Event {
ev := r.escSequence(&sz) ev := r.escSequence(&sz)
// Second chance // Second chance
if ev.Type == Invalid { if ev.Type == Invalid {
r.buffer, result, err = r.getBytes(true) if r.buffer, err = r.getBytes(); err != nil {
if err != nil {
return Event{Fatal, 0, nil} return Event{Fatal, 0, nil}
} }
if result == getCharCancelled {
return Event{Invalid, 0, nil}
}
ev = r.escSequence(&sz) ev = r.escSequence(&sz)
} }
return ev return ev
@@ -396,21 +371,6 @@ func (r *LightRenderer) GetChar(cancellable bool) Event {
return Event{Rune, char, nil} 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 { func (r *LightRenderer) escSequence(sz *int) Event {
if len(r.buffer) < 2 { if len(r.buffer) < 2 {
return Event{Esc, 0, nil} return Event{Esc, 0, nil}
@@ -690,7 +650,6 @@ func (r *LightRenderer) escSequence(sz *int) Event {
switch r.buffer[4] { switch r.buffer[4] {
case '1', '2', '3', '4', '5', '6', '7', '8', '9': case '1', '2', '3', '4', '5', '6', '7', '8', '9':
// Kitty iTerm2 WezTerm // Kitty iTerm2 WezTerm
// ARROW "\e[1;1D"
// SHIFT-ARROW "\e[1;2D" // SHIFT-ARROW "\e[1;2D"
// ALT-SHIFT-ARROW "\e[1;4D" "\e[1;10D" "\e[1;4D" // ALT-SHIFT-ARROW "\e[1;4D" "\e[1;10D" "\e[1;4D"
// CTRL-SHIFT-ARROW "\e[1;6D" N/A // CTRL-SHIFT-ARROW "\e[1;6D" N/A
@@ -745,7 +704,6 @@ func (r *LightRenderer) escSequence(sz *int) Event {
if shift { if shift {
return Event{ShiftUp, 0, nil} return Event{ShiftUp, 0, nil}
} }
return Event{Up, 0, nil}
case 'B': case 'B':
if ctrlAltShift { if ctrlAltShift {
return Event{CtrlAltShiftDown, 0, nil} return Event{CtrlAltShiftDown, 0, nil}
@@ -768,7 +726,6 @@ func (r *LightRenderer) escSequence(sz *int) Event {
if shift { if shift {
return Event{ShiftDown, 0, nil} return Event{ShiftDown, 0, nil}
} }
return Event{Down, 0, nil}
case 'C': case 'C':
if ctrlAltShift { if ctrlAltShift {
return Event{CtrlAltShiftRight, 0, nil} return Event{CtrlAltShiftRight, 0, nil}
@@ -791,7 +748,6 @@ func (r *LightRenderer) escSequence(sz *int) Event {
if alt { if alt {
return Event{AltRight, 0, nil} return Event{AltRight, 0, nil}
} }
return Event{Right, 0, nil}
case 'D': case 'D':
if ctrlAltShift { if ctrlAltShift {
return Event{CtrlAltShiftLeft, 0, nil} return Event{CtrlAltShiftLeft, 0, nil}
@@ -814,7 +770,6 @@ func (r *LightRenderer) escSequence(sz *int) Event {
if shift { if shift {
return Event{ShiftLeft, 0, nil} return Event{ShiftLeft, 0, nil}
} }
return Event{Left, 0, nil}
case 'H': case 'H':
if ctrlAltShift { if ctrlAltShift {
return Event{CtrlAltShiftHome, 0, nil} return Event{CtrlAltShiftHome, 0, nil}
@@ -837,7 +792,6 @@ func (r *LightRenderer) escSequence(sz *int) Event {
if shift { if shift {
return Event{ShiftHome, 0, nil} return Event{ShiftHome, 0, nil}
} }
return Event{Home, 0, nil}
case 'F': case 'F':
if ctrlAltShift { if ctrlAltShift {
return Event{CtrlAltShiftEnd, 0, nil} return Event{CtrlAltShiftEnd, 0, nil}
@@ -860,7 +814,6 @@ func (r *LightRenderer) escSequence(sz *int) Event {
if shift { if shift {
return Event{ShiftEnd, 0, nil} return Event{ShiftEnd, 0, nil}
} }
return Event{End, 0, nil}
} }
} // r.buffer[4] } // r.buffer[4]
} // r.buffer[3] } // r.buffer[3]
@@ -905,15 +858,12 @@ func (r *LightRenderer) mouseSequence(sz *int) Event {
down := rest[end] == 'M' down := rest[end] == 'M'
scroll := 0 scroll := 0
wheel := t >= 64 if t >= 64 {
if wheel {
t -= 64 t -= 64
// SGR wheel button codes: 64=up, 65=down, 66=left, 67=right if t&0b1 == 1 {
switch t & 0b11 {
case 0:
scroll = 1
case 1:
scroll = -1 scroll = -1
} else {
scroll = 1
} }
} }
@@ -924,7 +874,7 @@ func (r *LightRenderer) mouseSequence(sz *int) Event {
shift := t&0b00100 > 0 shift := t&0b00100 > 0
drag := t&0b100000 > 0 // 32 drag := t&0b100000 > 0 // 32
if wheel { if scroll != 0 {
return Event{Mouse, 0, &MouseEvent{y, x, scroll, false, false, false, ctrl, alt, shift}} return Event{Mouse, 0, &MouseEvent{y, x, scroll, false, false, false, ctrl, alt, shift}}
} }
@@ -1083,8 +1033,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 { func (r *LightRenderer) NewWindow(top int, left int, width int, height int, windowType WindowType, borderStyle BorderStyle, erase bool) Window {
width = max(0, width) width = util.Max(0, width)
height = max(0, height) height = util.Max(0, height)
w := &LightWindow{ w := &LightWindow{
renderer: r, renderer: r,
colored: r.theme.Colored, colored: r.theme.Colored,
@@ -1133,144 +1083,127 @@ func (w *LightWindow) DrawHBorder() {
w.drawBorder(true) w.drawBorder(true)
} }
// drawHLine fills row `row` with `line` between optional left/right caps.
// A zero rune means "no cap"; caps are placed at the very edges of `w`.
func (w *LightWindow) drawHLine(row int, line, leftCap, rightCap rune, color ColorPair) {
w.Move(row, 0)
hw := runeWidth(line)
width := w.width
if leftCap != 0 {
w.CPrint(color, string(leftCap))
width -= runeWidth(leftCap)
}
if rightCap != 0 {
width -= runeWidth(rightCap)
}
if width < 0 {
width = 0
}
inner := width / hw
rem := width - inner*hw
w.CPrint(color, repeat(line, inner)+repeat(' ', rem))
if rightCap != 0 {
w.CPrint(color, string(rightCap))
}
}
func (w *LightWindow) DrawHSeparator(row int, windowType WindowType, useBottom bool) {
if w.height == 0 {
return
}
shape := w.border.shape
if shape == BorderNone {
return
}
color := BorderColor(windowType)
line := w.border.top
if useBottom {
line = w.border.bottom
}
var leftCap, rightCap rune
if shape.HasLeft() {
leftCap = w.border.leftMid
}
if shape.HasRight() {
rightCap = w.border.rightMid
}
w.drawHLine(row, line, leftCap, rightCap, color)
}
func (w *LightWindow) PaintSectionFrame(topContent, bottomContent int, windowType WindowType, edge SectionEdge) {
if w.height == 0 || w.border.shape == BorderNone {
return
}
color := BorderColor(windowType)
shape := w.border.shape
hasLeft := shape.HasLeft()
hasRight := shape.HasRight()
rightW := runeWidth(w.border.right)
// Content rows: overpaint left/right verticals + their 1-char margin.
for row := topContent; row <= bottomContent; row++ {
if hasLeft {
w.Move(row, 0)
w.CPrint(color, string(w.border.left)+" ")
}
if hasRight {
w.Move(row, w.width-rightW-1)
w.CPrint(color, " "+string(w.border.right))
}
}
if edge == SectionEdgeTop && shape.HasTop() {
var leftCap, rightCap rune
if hasLeft {
leftCap = w.border.topLeft
}
if hasRight {
rightCap = w.border.topRight
}
w.drawHLine(0, w.border.top, leftCap, rightCap, color)
}
if edge == SectionEdgeBottom && shape.HasBottom() {
var leftCap, rightCap rune
if hasLeft {
leftCap = w.border.bottomLeft
}
if hasRight {
rightCap = w.border.bottomRight
}
w.drawHLine(w.height-1, w.border.bottom, leftCap, rightCap, color)
}
}
func (w *LightWindow) drawBorder(onlyHorizontal bool) { func (w *LightWindow) drawBorder(onlyHorizontal bool) {
if w.height == 0 { if w.height == 0 {
return return
} }
shape := w.border.shape switch w.border.shape {
if shape == BorderNone { case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble:
return 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)
} }
color := BorderColor(w.windowType) }
hasLeft := shape.HasLeft()
hasRight := shape.HasRight()
if shape.HasTop() { func (w *LightWindow) drawBorderHorizontal(top, bottom bool) {
var leftCap, rightCap rune color := ColBorder
if hasLeft { switch w.windowType {
leftCap = w.border.topLeft case WindowList:
} color = ColListBorder
if hasRight { case WindowInput:
rightCap = w.border.topRight color = ColInputBorder
} case WindowHeader:
w.drawHLine(0, w.border.top, leftCap, rightCap, color) color = ColHeaderBorder
case WindowFooter:
color = ColFooterBorder
case WindowPreview:
color = ColPreviewBorder
} }
if !onlyHorizontal && (hasLeft || hasRight) { 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 right {
w.Move(y, w.width-vw-1)
w.CPrint(color, " ") // Margin
w.CPrint(color, string(w.border.right))
}
}
}
func (w *LightWindow) drawBorderAround(onlyHorizontal bool) {
w.Move(0, 0)
color := ColBorder
switch w.windowType {
case WindowList:
color = ColListBorder
case WindowInput:
color = ColInputBorder
case WindowHeader:
color = ColHeaderBorder
case WindowFooter:
color = ColFooterBorder
case WindowPreview:
color = ColPreviewBorder
}
hw := runeWidth(w.border.top)
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 {
vw := runeWidth(w.border.left) vw := runeWidth(w.border.left)
for y := 0; y < w.height; y++ { for y := 1; y < w.height-1; y++ {
// Corner rows are already painted by drawHLine above / below. w.Move(y, 0)
if (y == 0 && shape.HasTop()) || (y == w.height-1 && shape.HasBottom()) { w.CPrint(color, string(w.border.left))
continue w.CPrint(color, " ") // Margin
}
if hasLeft { w.Move(y, w.width-vw-1)
w.Move(y, 0) w.CPrint(color, " ") // Margin
w.CPrint(color, string(w.border.left)+" ") w.CPrint(color, string(w.border.right))
}
if hasRight {
w.Move(y, w.width-vw-1)
w.CPrint(color, " "+string(w.border.right))
}
} }
} }
if shape.HasBottom() { w.Move(w.height-1, 0)
var leftCap, rightCap rune rem = (w.width - bcw) % hw
if hasLeft { w.CPrint(color, string(w.border.bottomLeft)+repeat(w.border.bottom, (w.width-bcw)/hw)+repeat(' ', rem)+string(w.border.bottomRight))
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 { func (w *LightWindow) csi(code string) string {
@@ -1350,18 +1283,7 @@ func attrCodes(attr Attr) []string {
codes = append(codes, "3") codes = append(codes, "3")
} }
if (attr & Underline) > 0 { if (attr & Underline) > 0 {
switch attr.UnderlineStyle() { codes = append(codes, "4")
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 { if (attr & Blink) > 0 {
codes = append(codes, "5") codes = append(codes, "5")
@@ -1399,27 +1321,8 @@ func colorCodes(fg Color, bg Color) []string {
return codes return codes
} }
func ulColorCode(c Color) string { func (w *LightWindow) csiColor(fg Color, bg Color, attr Attr) (bool, 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)...) codes := append(attrCodes(attr), colorCodes(fg, bg)...)
if ulCode := ulColorCode(ul); ulCode != "" {
codes = append(codes, ulCode)
}
code := w.csi(";" + strings.Join(codes, ";") + "m") code := w.csi(";" + strings.Join(codes, ";") + "m")
return len(codes) > 0, code return len(codes) > 0, code
} }
@@ -1433,28 +1336,65 @@ func cleanse(str string) string {
} }
func (w *LightWindow) CPrint(pair ColorPair, text string) { func (w *LightWindow) CPrint(pair ColorPair, text string) {
_, code := w.csiColor(pair.Fg(), pair.Bg(), pair.Ul(), pair.Attr()) _, code := w.csiColor(pair.Fg(), pair.Bg(), pair.Attr())
w.stderrInternal(cleanse(text), false, code) w.stderrInternal(cleanse(text), false, code)
w.csi("0m") w.csi("0m")
} }
func (w *LightWindow) cprint2(fg Color, bg Color, attr Attr, text string) { func (w *LightWindow) cprint2(fg Color, bg Color, attr Attr, text string) {
hasColors, code := w.csiColor(fg, bg, colDefault, attr) hasColors, code := w.csiColor(fg, bg, attr)
if hasColors { if hasColors {
defer w.csi("0m") defer w.csi("0m")
} }
w.stderrInternal(cleanse(text), false, code) 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 { func (w *LightWindow) fill(str string, resetCode string) FillReturn {
allLines := strings.Split(str, "\n") allLines := strings.Split(str, "\n")
for i, line := range allLines { 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 { for j, wl := range lines {
if w.posx < w.width { w.stderrInternal(wl.text, false, resetCode)
w.stderrInternal(wl.Text, false, resetCode) w.posx += wl.displayWidth
w.posx += wl.DisplayWidth
}
// Wrap line // Wrap line
if j < len(lines)-1 || i < len(allLines)-1 { if j < len(lines)-1 || i < len(allLines)-1 {
@@ -1492,7 +1432,7 @@ func (w *LightWindow) fill(str string, resetCode string) FillReturn {
func (w *LightWindow) setBg() string { func (w *LightWindow) setBg() string {
if w.bg != colDefault { if w.bg != colDefault {
_, code := w.csiColor(colDefault, w.bg, colDefault, AttrRegular) _, code := w.csiColor(colDefault, w.bg, AttrRegular)
return code return code
} }
// Should clear dim attribute after ␍ in the preview window // Should clear dim attribute after ␍ in the preview window
@@ -1514,7 +1454,7 @@ func (w *LightWindow) Fill(text string) FillReturn {
return w.fill(text, code) return w.fill(text, code)
} }
func (w *LightWindow) CFill(fg Color, bg Color, ul Color, attr Attr, text string) FillReturn { func (w *LightWindow) CFill(fg Color, bg Color, attr Attr, text string) FillReturn {
w.Move(w.posy, w.posx) w.Move(w.posy, w.posx)
if fg == colDefault { if fg == colDefault {
fg = w.fg fg = w.fg
@@ -1522,7 +1462,7 @@ func (w *LightWindow) CFill(fg Color, bg Color, ul Color, attr Attr, text string
if bg == colDefault { if bg == colDefault {
bg = w.bg bg = w.bg
} }
if hasColors, resetCode := w.csiColor(fg, bg, ul, attr); hasColors { if hasColors, resetCode := w.csiColor(fg, bg, attr); hasColors {
defer w.csi("0m") defer w.csi("0m")
return w.fill(text, resetCode) return w.fill(text, resetCode)
} }
+1 -87
View File
@@ -3,7 +3,6 @@ package tui
import ( import (
"fmt" "fmt"
"os" "os"
"strings"
"testing" "testing"
"unicode" "unicode"
) )
@@ -16,27 +15,10 @@ func TestLightRenderer(t *testing.T) {
light_renderer := renderer.(*LightRenderer) 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) { assertCharSequence := func(sequence string, name string) {
bytes := []byte(sequence) bytes := []byte(sequence)
light_renderer.buffer = bytes light_renderer.buffer = bytes
event := light_renderer.GetChar(true) event := light_renderer.GetChar()
if event.KeyName() != name { if event.KeyName() != name {
t.Errorf( t.Errorf(
"sequence: %q | %v | '%s' (%s) != %s", "sequence: %q | %v | '%s' (%s) != %s",
@@ -351,71 +333,3 @@ func TestLightRenderer(t *testing.T) {
assertEscSequence("\x1b[14~", "f4") assertEscSequence("\x1b[14~", "f4")
} }
func TestLightRendererScrollWheel(t *testing.T) {
tty_file, _ := os.Open("")
renderer, _ := NewLightRenderer(
"", tty_file, &ColorTheme{}, true, true, 0, false, true,
func(h int) int { return h })
light_renderer := renderer.(*LightRenderer)
assertScroll := func(sequence string, scroll int, mods string) {
bytes := []byte(sequence)
light_renderer.buffer = bytes
sz := 1
event := light_renderer.escSequence(&sz)
me := event.MouseEvent
if event.Type != Mouse || me == nil {
t.Errorf("sequence: %q | got %s, want a Mouse event", sequence, event.Type.String())
return
}
got := ""
if me.Ctrl {
got += "ctrl-"
}
if me.Alt {
got += "alt-"
}
if me.Shift {
got += "shift-"
}
got = strings.TrimSuffix(got, "-")
if me.S != scroll || got != mods || me.Down {
t.Errorf(
"sequence: %q | scroll=%d mods=%q down=%v != scroll=%d mods=%q down=false",
sequence, me.S, got, me.Down, scroll, mods)
}
}
assertScroll("\x1b[<64;1;1M", 1, "") // up
assertScroll("\x1b[<65;1;1M", -1, "") // down
assertScroll("\x1b[<66;1;1M", 0, "") // left (ignored)
assertScroll("\x1b[<67;1;1M", 0, "") // right (ignored)
assertScroll("\x1b[<68;1;1M", 1, "shift") // shift + up
assertScroll("\x1b[<69;1;1M", -1, "shift") // shift + down
assertScroll("\x1b[<70;1;1M", 0, "shift") // shift + left (ignored)
assertScroll("\x1b[<71;1;1M", 0, "shift") // shift + right (ignored)
assertScroll("\x1b[<72;1;1M", 1, "alt") // alt + up
assertScroll("\x1b[<73;1;1M", -1, "alt") // alt + down
assertScroll("\x1b[<74;1;1M", 0, "alt") // alt + left (ignored)
assertScroll("\x1b[<75;1;1M", 0, "alt") // alt + right (ignored)
assertScroll("\x1b[<80;1;1M", 1, "ctrl") // ctrl + up
assertScroll("\x1b[<81;1;1M", -1, "ctrl") // ctrl + down
assertScroll("\x1b[<82;1;1M", 0, "ctrl") // ctrl + left (ignored)
assertScroll("\x1b[<83;1;1M", 0, "ctrl") // ctrl + right (ignored)
assertScroll("\x1b[<84;1;1M", 1, "ctrl-shift") // ctrl+shift + up
assertScroll("\x1b[<85;1;1M", -1, "ctrl-shift") // ctrl+shift + down
assertScroll("\x1b[<86;1;1M", 0, "ctrl-shift") // ctrl+shift + left (ignored)
assertScroll("\x1b[<87;1;1M", 0, "ctrl-shift") // ctrl+shift + right (ignored)
assertScroll("\x1b[<92;1;1M", 1, "ctrl-alt-shift") // ctrl+alt+shift + up
assertScroll("\x1b[<93;1;1M", -1, "ctrl-alt-shift") // ctrl+alt+shift + down
assertScroll("\x1b[<94;1;1M", 0, "ctrl-alt-shift") // ctrl+alt+shift + left (ignored)
assertScroll("\x1b[<95;1;1M", 0, "ctrl-alt-shift") // ctrl+alt+shift + right (ignored)
}
+7 -54
View File
@@ -99,7 +99,7 @@ func (r *LightRenderer) findOffset() (row int, col int) {
var err error var err error
bytes := []byte{} bytes := []byte{}
for tries := range offsetPollTries { for tries := range offsetPollTries {
bytes, _, err = r.getBytesInternal(false, bytes, tries > 0) bytes, err = r.getBytesInternal(bytes, tries > 0)
if err != nil { if err != nil {
return -1, -1 return -1, -1
} }
@@ -114,62 +114,15 @@ func (r *LightRenderer) findOffset() (row int, col int) {
return -1, -1 return -1, -1
} }
func (r *LightRenderer) getch(cancellable bool, nonblock bool) (int, getCharResult) { func (r *LightRenderer) getch(nonblock bool) (int, bool) {
b := make([]byte, 1)
fd := r.fd() fd := r.fd()
getter := func() (int, getCharResult) { util.SetNonblock(r.ttyin, nonblock)
b := make([]byte, 1) _, err := util.Read(fd, b)
util.SetNonblock(r.ttyin, nonblock)
_, err := util.Read(fd, b)
if err != nil {
return 0, getCharError
}
return int(b[0]), getCharSuccess
}
if nonblock || !cancellable {
return getter()
}
rpipe, wpipe, err := os.Pipe()
if err != nil { if err != nil {
// Fallback to blocking read without cancellation return 0, false
return getter()
} }
r.setCancel(func() { return int(b[0]), true
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 { func (r *LightRenderer) Size() TermSize {
+10 -27
View File
@@ -151,33 +151,16 @@ func (r *LightRenderer) findOffset() (row int, col int) {
return int(bufferInfo.CursorPosition.Y), int(bufferInfo.CursorPosition.X) return int(bufferInfo.CursorPosition.Y), int(bufferInfo.CursorPosition.X)
} }
func (r *LightRenderer) getch(cancellable bool, nonblock bool) (int, getCharResult) { func (r *LightRenderer) getch(nonblock bool) (int, bool) {
if !nonblock && !cancellable {
bc := <-r.ttyinChannel
return int(bc), getCharSuccess
}
var timeout <-chan time.Time
if nonblock { if nonblock {
timeout = time.After(timeoutInterval * time.Millisecond) select {
} case bc := <-r.ttyinChannel:
return int(bc), true
var cancel chan struct{} case <-time.After(timeoutInterval * time.Millisecond):
if cancellable { return 0, false
cancel = make(chan struct{}) }
r.setCancel(func() { } else {
close(cancel) bc := <-r.ttyinChannel
}) return int(bc), true
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
} }
} }
+112 -229
View File
@@ -5,7 +5,6 @@ package tui
import ( import (
"os" "os"
"regexp" "regexp"
"strings"
"time" "time"
"github.com/gdamore/tcell/v2" "github.com/gdamore/tcell/v2"
@@ -54,7 +53,6 @@ type TcellWindow struct {
showCursor bool showCursor bool
wrapSign string wrapSign string
wrapSignWidth int wrapSignWidth int
tabstop int
} }
func (w *TcellWindow) Top() int { func (w *TcellWindow) Top() int {
@@ -248,7 +246,7 @@ func (r *FullscreenRenderer) Size() TermSize {
return TermSize{lines, cols, 0, 0} return TermSize{lines, cols, 0, 0}
} }
func (r *FullscreenRenderer) GetChar(cancellable bool) Event { func (r *FullscreenRenderer) GetChar() Event {
ev := _screen.PollEvent() ev := _screen.PollEvent()
switch ev := ev.(type) { switch ev := ev.(type) {
case *tcell.EventPaste: case *tcell.EventPaste:
@@ -705,10 +703,6 @@ func (r *FullscreenRenderer) GetChar(cancellable bool) Event {
return Event{Invalid, 0, nil} return Event{Invalid, 0, nil}
} }
func (r *FullscreenRenderer) CancelGetChar() {
// TODO
}
func (r *FullscreenRenderer) Pause(clear bool) { func (r *FullscreenRenderer) Pause(clear bool) {
if clear { if clear {
_screen.Suspend() _screen.Suspend()
@@ -735,8 +729,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 { func (r *FullscreenRenderer) NewWindow(top int, left int, width int, height int, windowType WindowType, borderStyle BorderStyle, erase bool) Window {
width = max(0, width) width = util.Max(0, width)
height = max(0, height) height = util.Max(0, height)
normal := ColBorder normal := ColBorder
switch windowType { switch windowType {
case WindowList: case WindowList:
@@ -759,8 +753,7 @@ func (r *FullscreenRenderer) NewWindow(top int, left int, width int, height int,
height: height, height: height,
normal: normal, normal: normal,
borderStyle: borderStyle, borderStyle: borderStyle,
showCursor: r.showCursor, showCursor: r.showCursor}
tabstop: r.tabstop}
w.Erase() w.Erase()
return w return w
} }
@@ -828,21 +821,6 @@ func (w *TcellWindow) withUrl(style tcell.Style) tcell.Style {
return 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) { func (w *TcellWindow) printString(text string, pair ColorPair) {
lx := 0 lx := 0
a := pair.Attr() a := pair.Attr()
@@ -851,18 +829,11 @@ func (w *TcellWindow) printString(text string, pair ColorPair) {
if a&AttrClear == 0 { if a&AttrClear == 0 {
style = style. style = style.
Reverse(a&Attr(tcell.AttrReverse) != 0). Reverse(a&Attr(tcell.AttrReverse) != 0).
Underline(a&Attr(tcell.AttrUnderline) != 0).
StrikeThrough(a&Attr(tcell.AttrStrikeThrough) != 0). StrikeThrough(a&Attr(tcell.AttrStrikeThrough) != 0).
Italic(a&Attr(tcell.AttrItalic) != 0). Italic(a&Attr(tcell.AttrItalic) != 0).
Blink(a&Attr(tcell.AttrBlink) != 0). Blink(a&Attr(tcell.AttrBlink) != 0).
Dim(a&Attr(tcell.AttrDim) != 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) style = w.withUrl(style)
@@ -897,8 +868,10 @@ func (w *TcellWindow) CPrint(pair ColorPair, text string) {
w.printString(text, pair) w.printString(text, pair)
} }
func (w *TcellWindow) pairStyle(pair ColorPair) tcell.Style { func (w *TcellWindow) fillString(text string, pair ColorPair) FillReturn {
lx := 0
a := pair.Attr() a := pair.Attr()
var style tcell.Style var style tcell.Style
if w.color { if w.color {
style = pair.style() style = pair.style()
@@ -910,73 +883,64 @@ func (w *TcellWindow) pairStyle(pair ColorPair) tcell.Style {
Bold(a&Attr(tcell.AttrBold) != 0 || a&BoldForce != 0). Bold(a&Attr(tcell.AttrBold) != 0 || a&BoldForce != 0).
Dim(a&Attr(tcell.AttrDim) != 0). Dim(a&Attr(tcell.AttrDim) != 0).
Reverse(a&Attr(tcell.AttrReverse) != 0). Reverse(a&Attr(tcell.AttrReverse) != 0).
Underline(a&Attr(tcell.AttrUnderline) != 0).
StrikeThrough(a&Attr(tcell.AttrStrikeThrough) != 0). StrikeThrough(a&Attr(tcell.AttrStrikeThrough) != 0).
Italic(a&Attr(tcell.AttrItalic) != 0) Italic(a&Attr(tcell.AttrItalic) != 0)
if a&Attr(tcell.AttrUnderline) != 0 { style = w.withUrl(style)
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) gr := uniseg.NewGraphemes(text)
Loop:
for gr.Next() { for gr.Next() {
st := style st := style
rs := gr.Runes() rs := gr.Runes()
if len(rs) == 1 && rs[0] == '\r' { if len(rs) == 1 {
st = style.Dim(true) r := rs[0]
rs[0] = '␍' switch r {
} case '\r':
st = style.Dim(true)
xPos := w.left + w.lastX rs[0] = '␍'
yPos := w.top + w.lastY case '\n':
if xPos < (w.left+w.width) && yPos < (w.top+w.height) {
_screen.SetContent(xPos, yPos, rs[0], rs[1:], st)
}
w.lastX += util.StringWidth(string(rs))
}
}
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++ w.lastY++
if w.lastY >= w.height {
return FillSuspend
}
w.lastX = 0 w.lastX = 0
if j > 0 { lx = 0
w.renderWrapSign(style) continue Loop
}
}
if w.lastX < w.width {
w.renderGraphemes(wl.Text, style)
} }
} }
// 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
}
yPos := w.top + w.lastY
if yPos >= (w.top + w.height) {
return FillSuspend
}
_screen.SetContent(xPos, yPos, rs[0], rs[1:], st)
lx += util.StringWidth(string(rs))
} }
if w.lastX >= w.width { w.lastX += lx
if w.lastX == w.width {
w.lastY++ w.lastY++
w.lastX = 0 w.lastX = 0
return FillNextLine return FillNextLine
@@ -999,14 +963,14 @@ func (w *TcellWindow) LinkEnd() {
w.params = nil w.params = nil
} }
func (w *TcellWindow) CFill(fg Color, bg Color, ul Color, a Attr, str string) FillReturn { func (w *TcellWindow) CFill(fg Color, bg Color, a Attr, str string) FillReturn {
if fg == colDefault { if fg == colDefault {
fg = w.normal.Fg() fg = w.normal.Fg()
} }
if bg == colDefault { if bg == colDefault {
bg = w.normal.Bg() bg = w.normal.Bg()
} }
return w.fillString(str, NewColorPair(fg, bg, a).WithUl(ul)) return w.fillString(str, NewColorPair(fg, bg, a))
} }
func (w *TcellWindow) DrawBorder() { func (w *TcellWindow) DrawBorder() {
@@ -1017,115 +981,6 @@ func (w *TcellWindow) DrawHBorder() {
w.drawBorder(true) w.drawBorder(true)
} }
// borderStyleFor returns the tcell.Style used to draw borders for `wt`, honoring
// whether the window is rendering with colors.
func (w *TcellWindow) borderStyleFor(wt WindowType) tcell.Style {
if !w.color {
return w.normal.style()
}
return BorderColor(wt).style()
}
// drawHLine fills row `y` with `line` between optional left/right caps.
// A zero rune means "no cap"; caps are placed at the very edges of `w`.
// tcell has an issue displaying two overlapping wide runes, so the line
// stops before the cap position rather than overpainting.
func (w *TcellWindow) drawHLine(y int, line, leftCap, rightCap rune, style tcell.Style) {
left := w.left
right := left + w.width
hw := runeWidth(line)
lw := 0
rw := 0
if leftCap != 0 {
lw = runeWidth(leftCap)
}
if rightCap != 0 {
rw = runeWidth(rightCap)
}
for x := left + lw; x <= right-rw-hw; x += hw {
_screen.SetContent(x, y, line, nil, style)
}
if leftCap != 0 {
_screen.SetContent(left, y, leftCap, nil, style)
}
if rightCap != 0 {
_screen.SetContent(right-rw, y, rightCap, nil, style)
}
}
func (w *TcellWindow) DrawHSeparator(row int, windowType WindowType, useBottom bool) {
if w.height == 0 {
return
}
shape := w.borderStyle.shape
if shape == BorderNone {
return
}
style := w.borderStyleFor(windowType)
line := w.borderStyle.top
if useBottom {
line = w.borderStyle.bottom
}
var leftCap, rightCap rune
if shape.HasLeft() {
leftCap = w.borderStyle.leftMid
}
if shape.HasRight() {
rightCap = w.borderStyle.rightMid
}
w.drawHLine(w.top+row, line, leftCap, rightCap, style)
}
func (w *TcellWindow) PaintSectionFrame(topContent, bottomContent int, windowType WindowType, edge SectionEdge) {
if w.height == 0 {
return
}
shape := w.borderStyle.shape
if shape == BorderNone {
return
}
style := w.borderStyleFor(windowType)
left := w.left
right := left + w.width
hasLeft := shape.HasLeft()
hasRight := shape.HasRight()
leftW := runeWidth(w.borderStyle.left)
rightW := runeWidth(w.borderStyle.right)
// Content rows: overpaint the left and right verticals (+ their 1-char margin) in
// the section's color. Inner margin stays at whatever bg the sub-window set.
for row := topContent; row <= bottomContent; row++ {
y := w.top + row
if hasLeft {
_screen.SetContent(left, y, w.borderStyle.left, nil, style)
_screen.SetContent(left+leftW, y, ' ', nil, style)
}
if hasRight {
_screen.SetContent(right-rightW-1, y, ' ', nil, style)
_screen.SetContent(right-rightW, y, w.borderStyle.right, nil, style)
}
}
if edge == SectionEdgeTop && shape.HasTop() {
var leftCap, rightCap rune
if hasLeft {
leftCap = w.borderStyle.topLeft
}
if hasRight {
rightCap = w.borderStyle.topRight
}
w.drawHLine(w.top, w.borderStyle.top, leftCap, rightCap, style)
}
if edge == SectionEdgeBottom && shape.HasBottom() {
var leftCap, rightCap rune
if hasLeft {
leftCap = w.borderStyle.bottomLeft
}
if hasRight {
rightCap = w.borderStyle.bottomRight
}
w.drawHLine(w.top+w.height-1, w.borderStyle.bottom, leftCap, rightCap, style)
}
}
func (w *TcellWindow) drawBorder(onlyHorizontal bool) { func (w *TcellWindow) drawBorder(onlyHorizontal bool) {
if w.height == 0 { if w.height == 0 {
return return
@@ -1140,44 +995,72 @@ func (w *TcellWindow) drawBorder(onlyHorizontal bool) {
top := w.top top := w.top
bot := top + w.height bot := top + w.height
style := w.borderStyleFor(w.windowType) var style tcell.Style
if w.color {
hasLeft := shape.HasLeft() switch w.windowType {
hasRight := shape.HasRight() case WindowBase:
style = ColBorder.style()
if shape.HasTop() { case WindowList:
var leftCap, rightCap rune style = ColListBorder.style()
if hasLeft { case WindowHeader:
leftCap = w.borderStyle.topLeft style = ColHeaderBorder.style()
case WindowFooter:
style = ColFooterBorder.style()
case WindowInput:
style = ColInputBorder.style()
case WindowPreview:
style = ColPreviewBorder.style()
} }
if hasRight { } else {
rightCap = w.borderStyle.topRight style = w.normal.style()
}
w.drawHLine(top, w.borderStyle.top, leftCap, rightCap, style)
} }
if shape.HasBottom() {
var leftCap, rightCap rune hw := runeWidth(w.borderStyle.top)
if hasLeft { switch shape {
leftCap = w.borderStyle.bottomLeft case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble, BorderHorizontal, BorderTop:
max := right - 2*hw
if shape == BorderHorizontal || shape == BorderTop {
max = right - hw
} }
if hasRight { // tcell has an issue displaying two overlapping wide runes
rightCap = w.borderStyle.bottomRight // 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)
}
}
switch shape {
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble, BorderHorizontal, BorderBottom:
max := right - 2*hw
if shape == BorderHorizontal || shape == BorderBottom {
max = right - hw
}
for x := left; x <= max; x += hw {
_screen.SetContent(x, bot-1, w.borderStyle.bottom, nil, style)
} }
w.drawHLine(bot-1, w.borderStyle.bottom, leftCap, rightCap, style)
} }
if !onlyHorizontal { if !onlyHorizontal {
vw := runeWidth(w.borderStyle.right) switch shape {
for y := top; y < bot; y++ { case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble, BorderVertical, BorderLeft:
// Corner rows are already painted by drawHLine above / below. for y := top; y < bot; y++ {
if (y == top && shape.HasTop()) || (y == bot-1 && shape.HasBottom()) {
continue
}
if hasLeft {
_screen.SetContent(left, y, w.borderStyle.left, nil, style) _screen.SetContent(left, y, w.borderStyle.left, nil, style)
} }
if hasRight { }
switch shape {
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble, BorderVertical, BorderRight:
vw := runeWidth(w.borderStyle.right)
for y := top; y < bot; y++ {
_screen.SetContent(right-vw, y, w.borderStyle.right, nil, style) _screen.SetContent(right-vw, y, w.borderStyle.right, nil, style)
} }
} }
} }
switch shape {
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble:
_screen.SetContent(left, top, w.borderStyle.topLeft, nil, style)
_screen.SetContent(right-runeWidth(w.borderStyle.topRight), top, w.borderStyle.topRight, nil, style)
_screen.SetContent(left, bot-1, w.borderStyle.bottomLeft, nil, style)
_screen.SetContent(right-runeWidth(w.borderStyle.bottomRight), bot-1, w.borderStyle.bottomRight, nil, style)
}
} }
+21 -21
View File
@@ -110,21 +110,21 @@ func TestGetCharEventKey(t *testing.T) {
{giveKey{tcell.KeyDelete, 0, tcell.ModAlt}, wantKey{AltDelete, 0, nil}}, {giveKey{tcell.KeyDelete, 0, tcell.ModAlt}, wantKey{AltDelete, 0, nil}},
{giveKey{tcell.KeyBackspace, 0, tcell.ModCtrl}, wantKey{CtrlBackspace, 0, nil}}, {giveKey{tcell.KeyBackspace, 0, tcell.ModCtrl}, wantKey{CtrlBackspace, 0, nil}},
{giveKey{tcell.KeyBackspace, 0, tcell.ModCtrl | tcell.ModAlt}, wantKey{CtrlAltBackspace, 0, nil}}, {giveKey{tcell.KeyBackspace, 0, tcell.ModCtrl | tcell.ModAlt}, wantKey{CtrlAltBackspace, 0, nil}},
{giveKey{tcell.KeyBackspace, 0, tcell.ModNone}, wantKey{Invalid, 0, nil}}, // fabricated, unhandled {giveKey{tcell.KeyBackspace, 0, tcell.ModNone}, wantKey{Invalid, 0, nil}}, // fabricated, unhandled
{giveKey{tcell.KeyBS, 0, tcell.ModNone}, wantKey{Invalid, 0, nil}}, // fabricated, unhandled {giveKey{tcell.KeyBS, 0, tcell.ModNone}, wantKey{Invalid, 0, nil}}, // fabricated, unhandled
{giveKey{tcell.KeyCtrlH, 0, tcell.ModNone}, wantKey{Invalid, 0, nil}}, // fabricated, unhandled {giveKey{tcell.KeyCtrlH, 0, tcell.ModNone}, wantKey{Invalid, 0, nil}}, // fabricated, unhandled
{giveKey{tcell.KeyCtrlH, rune(tcell.KeyCtrlH), tcell.ModNone}, wantKey{Backspace, 0, nil}}, // actual "Backspace" keystroke {giveKey{tcell.KeyCtrlH, rune(tcell.KeyCtrlH), tcell.ModNone}, wantKey{CtrlBackspace, 0, nil}}, // actual "Backspace" keystroke
{giveKey{tcell.KeyCtrlH, rune(tcell.KeyCtrlH), tcell.ModAlt}, wantKey{AltBackspace, 0, nil}}, // actual "Alt+Backspace" keystroke {giveKey{tcell.KeyCtrlH, rune(tcell.KeyCtrlH), tcell.ModAlt}, wantKey{AltBackspace, 0, nil}}, // actual "Alt+Backspace" keystroke
{giveKey{tcell.KeyDEL, rune(tcell.KeyDEL), tcell.ModCtrl}, wantKey{CtrlBackspace, 0, nil}}, // actual "Ctrl+Backspace" keystroke {giveKey{tcell.KeyDEL, rune(tcell.KeyDEL), tcell.ModCtrl}, wantKey{CtrlBackspace, 0, nil}}, // actual "Ctrl+Backspace" keystroke
{giveKey{tcell.KeyCtrlH, rune(tcell.KeyCtrlH), tcell.ModShift}, wantKey{Backspace, 0, nil}}, // actual "Shift+Backspace" keystroke {giveKey{tcell.KeyCtrlH, rune(tcell.KeyCtrlH), tcell.ModShift}, wantKey{CtrlBackspace, 0, nil}}, // actual "Shift+Backspace" keystroke
{giveKey{tcell.KeyCtrlH, 0, tcell.ModCtrl | tcell.ModAlt}, wantKey{CtrlAltBackspace, 0, nil}}, // actual "Ctrl+Alt+Backspace" keystroke {giveKey{tcell.KeyCtrlH, 0, tcell.ModCtrl | tcell.ModAlt}, wantKey{CtrlAltBackspace, 0, nil}}, // actual "Ctrl+Alt+Backspace" keystroke
{giveKey{tcell.KeyCtrlH, 0, tcell.ModCtrl | tcell.ModShift}, wantKey{CtrlBackspace, 0, nil}}, // actual "Ctrl+Shift+Backspace" keystroke {giveKey{tcell.KeyCtrlH, 0, tcell.ModCtrl | tcell.ModShift}, wantKey{CtrlBackspace, 0, nil}}, // actual "Ctrl+Shift+Backspace" keystroke
{giveKey{tcell.KeyCtrlH, rune(tcell.KeyCtrlH), tcell.ModShift | tcell.ModAlt}, wantKey{AltBackspace, 0, nil}}, // actual "Shift+Alt+Backspace" keystroke {giveKey{tcell.KeyCtrlH, rune(tcell.KeyCtrlH), tcell.ModShift | tcell.ModAlt}, wantKey{AltBackspace, 0, nil}}, // actual "Shift+Alt+Backspace" keystroke
{giveKey{tcell.KeyCtrlH, 0, tcell.ModCtrl | tcell.ModAlt | tcell.ModShift}, wantKey{CtrlAltBackspace, 0, nil}}, // actual "Ctrl+Shift+Alt+Backspace" keystroke {giveKey{tcell.KeyCtrlH, 0, tcell.ModCtrl | tcell.ModAlt | tcell.ModShift}, wantKey{CtrlAltBackspace, 0, nil}}, // actual "Ctrl+Shift+Alt+Backspace" keystroke
{giveKey{tcell.KeyCtrlH, rune(tcell.KeyCtrlH), tcell.ModCtrl}, wantKey{CtrlH, 0, nil}}, // actual "Ctrl+H" keystroke {giveKey{tcell.KeyCtrlH, rune(tcell.KeyCtrlH), tcell.ModCtrl}, wantKey{CtrlBackspace, 0, nil}}, // actual "Ctrl+H" keystroke
{giveKey{tcell.KeyCtrlH, rune(tcell.KeyCtrlH), tcell.ModCtrl | tcell.ModAlt}, wantKey{CtrlAlt, 'h', nil}}, // fabricated "Ctrl+Alt+H" keystroke {giveKey{tcell.KeyCtrlH, rune(tcell.KeyCtrlH), tcell.ModCtrl | tcell.ModAlt}, wantKey{AltBackspace, 0, nil}}, // fabricated "Ctrl+Alt+H" keystroke
{giveKey{tcell.KeyCtrlH, rune(tcell.KeyCtrlH), tcell.ModCtrl | tcell.ModShift}, wantKey{CtrlH, 0, nil}}, // actual "Ctrl+Shift+H" keystroke {giveKey{tcell.KeyCtrlH, rune(tcell.KeyCtrlH), tcell.ModCtrl | tcell.ModShift}, wantKey{CtrlBackspace, 0, nil}}, // actual "Ctrl+Shift+H" keystroke
{giveKey{tcell.KeyCtrlH, rune(tcell.KeyCtrlH), tcell.ModCtrl | tcell.ModAlt | tcell.ModShift}, wantKey{CtrlAlt, 'h', nil}}, // fabricated "Ctrl+Shift+Alt+H" keystroke {giveKey{tcell.KeyCtrlH, rune(tcell.KeyCtrlH), tcell.ModCtrl | tcell.ModAlt | tcell.ModShift}, wantKey{AltBackspace, 0, nil}}, // fabricated "Ctrl+Shift+Alt+H" keystroke
// section 4: (Alt+Shift)+Key(Up|Down|Left|Right) // section 4: (Alt+Shift)+Key(Up|Down|Left|Right)
{giveKey{tcell.KeyUp, 0, tcell.ModNone}, wantKey{Up, 0, nil}}, {giveKey{tcell.KeyUp, 0, tcell.ModNone}, wantKey{Up, 0, nil}},
@@ -253,7 +253,7 @@ func TestGetCharEventKey(t *testing.T) {
{giveKey{tcell.KeyPause, 0, tcell.ModNone}, wantKey{Invalid, 0, nil}}, // unhandled {giveKey{tcell.KeyPause, 0, tcell.ModNone}, wantKey{Invalid, 0, nil}}, // unhandled
} }
r := NewFullscreenRenderer(&ColorTheme{}, false, false, 8) r := NewFullscreenRenderer(&ColorTheme{}, false, false)
r.Init() r.Init()
// run and evaluate the tests // 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()) 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 // process the event in fzf and evaluate the test
gotEvent := r.GetChar(true) gotEvent := r.GetChar()
// skip Resize events, those are sometimes put in the buffer outside of this test // skip Resize events, those are sometimes put in the buffer outside of this test
if initialResizeAsInvalid && gotEvent.Type == Invalid { if initialResizeAsInvalid && gotEvent.Type == Invalid {
t.Logf("Resize as Invalid swallowed") t.Logf("Resize as Invalid swallowed")
initialResizeAsInvalid = false initialResizeAsInvalid = false
gotEvent = r.GetChar(true) gotEvent = r.GetChar()
} }
if gotEvent.Type == Resize { if gotEvent.Type == Resize {
t.Logf("Resize swallowed") t.Logf("Resize swallowed")
gotEvent = r.GetChar(true) gotEvent = r.GetChar()
} }
t.Logf("wantEvent = %T{Type: %v, Char: %q (%[3]v)}\n", test.wantKey, test.wantKey.Type, test.wantKey.Char) 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) t.Logf("gotEvent = %T{Type: %v, Char: %q (%[3]v)}\n", gotEvent, gotEvent.Type, gotEvent.Char)
assert(t, "r.GetChar(true).Type", gotEvent.Type, test.wantKey.Type) assert(t, "r.GetChar().Type", gotEvent.Type, test.wantKey.Type)
assert(t, "r.GetChar(true).Char", gotEvent.Char, test.wantKey.Char) assert(t, "r.GetChar().Char", gotEvent.Char, test.wantKey.Char)
} }
r.Close() r.Close()
+85 -296
View File
@@ -2,9 +2,7 @@ package tui
import ( import (
"strconv" "strconv"
"strings"
"time" "time"
"unicode"
"github.com/junegunn/fzf/src/util" "github.com/junegunn/fzf/src/util"
"github.com/rivo/uniseg" "github.com/rivo/uniseg"
@@ -19,34 +17,15 @@ const (
BoldForce = Attr(1 << 10) BoldForce = Attr(1 << 10)
FullBg = Attr(1 << 11) FullBg = Attr(1 << 11)
Strip = Attr(1 << 12) 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 { func (a Attr) Merge(b Attr) Attr {
if b&AttrRegular > 0 { if b&AttrRegular > 0 {
// Only keep bold attribute set by the system // Only keep bold attribute set by the system
return (b &^ AttrRegular) | (a & BoldForce) return (b &^ AttrRegular) | (a & BoldForce)
} }
merged := (a &^ AttrRegular) | b return (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 // Types of user action
@@ -64,7 +43,7 @@ const (
CtrlE CtrlE
CtrlF CtrlF
CtrlG CtrlG
CtrlH CtrlBackspace
Tab Tab
CtrlJ CtrlJ
CtrlK CtrlK
@@ -158,7 +137,6 @@ const (
CtrlRight CtrlRight
CtrlHome CtrlHome
CtrlEnd CtrlEnd
CtrlBackspace
CtrlDelete CtrlDelete
CtrlPageUp CtrlPageUp
CtrlPageDown CtrlPageDown
@@ -197,6 +175,11 @@ const (
CtrlAltShiftPageUp CtrlAltShiftPageUp
CtrlAltShiftPageDown CtrlAltShiftPageDown
Invalid
Fatal
BracketedPasteBegin
BracketedPasteEnd
Mouse Mouse
DoubleClick DoubleClick
LeftClick LeftClick
@@ -210,15 +193,7 @@ const (
PreviewScrollUp PreviewScrollUp
PreviewScrollDown PreviewScrollDown
// Synthetic / non-user events. Everything from Invalid onward is // Events
// either internally generated or a state-change notification, not
// direct user input. Use `>= Invalid` to gate activity tracking.
// BracketedPasteBegin/End sit here too: they enclose user input
// (which arrives as Rune events) and should not appear in FZF_KEY.
Invalid
Fatal
BracketedPasteBegin
BracketedPasteEnd
Resize Resize
Change Change
BackwardEOF BackwardEOF
@@ -233,8 +208,6 @@ const (
ClickHeader ClickHeader
ClickFooter ClickFooter
Multi Multi
Every
ResultFinal
) )
func (t EventType) AsEvent() Event { func (t EventType) AsEvent() Event {
@@ -254,12 +227,6 @@ func (e Event) Comparable() Event {
return Event{e.Type, e.Char, nil} return Event{e.Type, e.Char, nil}
} }
// Printable returns true if the event is a printable character that can be
// inserted into the query (e.g. via the 'put' action).
func (e Event) Printable() bool {
return e.Type == Rune && unicode.IsGraphic(e.Char)
}
func (e Event) KeyName() string { func (e Event) KeyName() string {
if me := e.MouseEvent; me != nil { if me := e.MouseEvent; me != nil {
return me.Name() return me.Name()
@@ -384,7 +351,6 @@ const (
type ColorPair struct { type ColorPair struct {
fg Color fg Color
bg Color bg Color
ul Color
attr Attr attr Attr
} }
@@ -396,11 +362,11 @@ func HexToColor(rrggbb string) Color {
} }
func NewColorPair(fg Color, bg Color, attr Attr) ColorPair { func NewColorPair(fg Color, bg Color, attr Attr) ColorPair {
return ColorPair{fg, bg, colDefault, attr} return ColorPair{fg, bg, attr}
} }
func NoColorPair() ColorPair { func NoColorPair() ColorPair {
return ColorPair{-1, -1, -1, 0} return ColorPair{-1, -1, 0}
} }
func (p ColorPair) Fg() Color { func (p ColorPair) Fg() Color {
@@ -411,16 +377,6 @@ func (p ColorPair) Bg() Color {
return p.bg 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 { func (p ColorPair) Attr() Attr {
return p.attr return p.attr
} }
@@ -447,9 +403,6 @@ func (p ColorPair) merge(other ColorPair, except Color) ColorPair {
if other.bg != except { if other.bg != except {
dup.bg = other.bg dup.bg = other.bg
} }
if other.ul != except {
dup.ul = other.ul
}
return dup return dup
} }
@@ -459,21 +412,15 @@ func (p ColorPair) WithAttr(attr Attr) ColorPair {
return dup return dup
} }
func (p ColorPair) WithNewAttr(attr Attr) ColorPair {
dup := p
dup.attr = attr
return dup
}
func (p ColorPair) WithFg(fg ColorAttr) ColorPair { func (p ColorPair) WithFg(fg ColorAttr) ColorPair {
dup := p dup := p
fgPair := ColorPair{fg.Color, colUndefined, colUndefined, fg.Attr} fgPair := ColorPair{fg.Color, colUndefined, fg.Attr}
return dup.Merge(fgPair) return dup.Merge(fgPair)
} }
func (p ColorPair) WithBg(bg ColorAttr) ColorPair { func (p ColorPair) WithBg(bg ColorAttr) ColorPair {
dup := p dup := p
bgPair := ColorPair{colUndefined, bg.Color, colUndefined, bg.Attr} bgPair := ColorPair{colUndefined, bg.Color, bg.Attr}
return dup.Merge(bgPair) return dup.Merge(bgPair)
} }
@@ -508,7 +455,6 @@ type ColorTheme struct {
PreviewBg ColorAttr PreviewBg ColorAttr
DarkBg ColorAttr DarkBg ColorAttr
Gutter ColorAttr Gutter ColorAttr
AltGutter ColorAttr
Prompt ColorAttr Prompt ColorAttr
InputBg ColorAttr InputBg ColorAttr
InputBorder ColorAttr InputBorder ColorAttr
@@ -518,7 +464,7 @@ type ColorTheme struct {
CurrentMatch ColorAttr CurrentMatch ColorAttr
Spinner ColorAttr Spinner ColorAttr
Info ColorAttr Info ColorAttr
Pointer ColorAttr Cursor ColorAttr
Marker ColorAttr Marker ColorAttr
Header ColorAttr Header ColorAttr
HeaderBg ColorAttr HeaderBg ColorAttr
@@ -538,8 +484,6 @@ type ColorTheme struct {
ListLabel ColorAttr ListLabel ColorAttr
ListBorder ColorAttr ListBorder ColorAttr
GapLine ColorAttr GapLine ColorAttr
NthCurrentAttr Attr // raw current-fg attr (before fg merge) for nth overlay
NthSelectedAttr Attr // raw selected-fg attr (before ListFg inherit) for nth overlay
} }
type Event struct { type Event struct {
@@ -607,13 +551,11 @@ const (
BorderBottom BorderBottom
BorderLeft BorderLeft
BorderRight BorderRight
BorderInline
BorderDashed
) )
func (s BorderShape) HasLeft() bool { func (s BorderShape) HasLeft() bool {
switch s { switch s {
case BorderNone, BorderPhantom, BorderLine, BorderInline, BorderRight, BorderTop, BorderBottom, BorderHorizontal: // No Left case BorderNone, BorderPhantom, BorderLine, BorderRight, BorderTop, BorderBottom, BorderHorizontal: // No Left
return false return false
} }
return true return true
@@ -621,7 +563,7 @@ func (s BorderShape) HasLeft() bool {
func (s BorderShape) HasRight() bool { func (s BorderShape) HasRight() bool {
switch s { switch s {
case BorderNone, BorderPhantom, BorderLine, BorderInline, BorderLeft, BorderTop, BorderBottom, BorderHorizontal: // No right case BorderNone, BorderPhantom, BorderLine, BorderLeft, BorderTop, BorderBottom, BorderHorizontal: // No right
return false return false
} }
return true return true
@@ -629,7 +571,7 @@ func (s BorderShape) HasRight() bool {
func (s BorderShape) HasTop() bool { func (s BorderShape) HasTop() bool {
switch s { switch s {
case BorderNone, BorderPhantom, BorderLine, BorderInline, BorderLeft, BorderRight, BorderBottom, BorderVertical: // No top case BorderNone, BorderPhantom, BorderLine, BorderLeft, BorderRight, BorderBottom, BorderVertical: // No top
return false return false
} }
return true return true
@@ -637,7 +579,7 @@ func (s BorderShape) HasTop() bool {
func (s BorderShape) HasBottom() bool { func (s BorderShape) HasBottom() bool {
switch s { switch s {
case BorderNone, BorderPhantom, BorderLine, BorderInline, BorderLeft, BorderRight, BorderTop, BorderVertical: // No bottom case BorderNone, BorderPhantom, BorderLine, BorderLeft, BorderRight, BorderTop, BorderVertical: // No bottom
return false return false
} }
return true return true
@@ -657,8 +599,6 @@ type BorderStyle struct {
topRight rune topRight rune
bottomLeft rune bottomLeft rune
bottomRight rune bottomRight rune
leftMid rune
rightMid rune
} }
type BorderCharacter int type BorderCharacter int
@@ -674,9 +614,7 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
topLeft: ' ', topLeft: ' ',
topRight: ' ', topRight: ' ',
bottomLeft: ' ', bottomLeft: ' ',
bottomRight: ' ', bottomRight: ' '}
leftMid: ' ',
rightMid: ' '}
} }
if !unicode { if !unicode {
return BorderStyle{ return BorderStyle{
@@ -689,8 +627,6 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
topRight: '+', topRight: '+',
bottomLeft: '+', bottomLeft: '+',
bottomRight: '+', bottomRight: '+',
leftMid: '+',
rightMid: '+',
} }
} }
switch shape { switch shape {
@@ -705,8 +641,6 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
topRight: '┐', topRight: '┐',
bottomLeft: '└', bottomLeft: '└',
bottomRight: '┘', bottomRight: '┘',
leftMid: '├',
rightMid: '┤',
} }
case BorderBold: case BorderBold:
return BorderStyle{ return BorderStyle{
@@ -719,8 +653,6 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
topRight: '┓', topRight: '┓',
bottomLeft: '┗', bottomLeft: '┗',
bottomRight: '┛', bottomRight: '┛',
leftMid: '┣',
rightMid: '┫',
} }
case BorderBlock: case BorderBlock:
// ▛▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▜ // ▛▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▜
@@ -736,8 +668,6 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
topRight: '▜', topRight: '▜',
bottomLeft: '▙', bottomLeft: '▙',
bottomRight: '▟', bottomRight: '▟',
leftMid: '▌',
rightMid: '▐',
} }
case BorderThinBlock: case BorderThinBlock:
@@ -754,8 +684,6 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
topRight: '🭾', topRight: '🭾',
bottomLeft: '🭼', bottomLeft: '🭼',
bottomRight: '🭿', bottomRight: '🭿',
leftMid: '▏',
rightMid: '▕',
} }
case BorderDouble: case BorderDouble:
@@ -769,25 +697,6 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
topRight: '╗', topRight: '╗',
bottomLeft: '╚', bottomLeft: '╚',
bottomRight: '╝', bottomRight: '╝',
leftMid: '╠',
rightMid: '╣',
}
case BorderDashed:
// Terminal cells are taller than wide (~2:1), so horizontals can use a
// sparse stub per cell while verticals need more dashes per cell to look
// evenly dashed. Rounded corners and sharp T-junction mids.
return BorderStyle{
shape: shape,
top: '╶',
bottom: '╶',
left: '┆',
right: '┆',
topLeft: '╭',
topRight: '╮',
bottomLeft: '╰',
bottomRight: '╯',
leftMid: '├',
rightMid: '┤',
} }
} }
return BorderStyle{ return BorderStyle{
@@ -800,8 +709,6 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
topRight: '╮', topRight: '╮',
bottomLeft: '╰', bottomLeft: '╰',
bottomRight: '╯', bottomRight: '╯',
leftMid: '├',
rightMid: '┤',
} }
} }
@@ -823,35 +730,6 @@ const (
WindowFooter WindowFooter
) )
// BorderColor returns the ColorPair used to draw borders for the given WindowType.
func BorderColor(wt WindowType) ColorPair {
switch wt {
case WindowList:
return ColListBorder
case WindowInput:
return ColInputBorder
case WindowHeader:
return ColHeaderBorder
case WindowFooter:
return ColFooterBorder
case WindowPreview:
return ColPreviewBorder
}
return ColBorder
}
// SectionEdge selects which outer edge of the frame an inline section
// should claim when PaintSectionFrame overpaints its adjacent border.
// SectionEdgeNone paints only the inner verticals (for sections that
// don't touch the outer top or bottom).
type SectionEdge int
const (
SectionEdgeNone SectionEdge = iota
SectionEdgeTop
SectionEdgeBottom
)
type Renderer interface { type Renderer interface {
DefaultTheme() *ColorTheme DefaultTheme() *ColorTheme
Init() error Init() error
@@ -869,8 +747,7 @@ type Renderer interface {
HideCursor() HideCursor()
ShowCursor() ShowCursor()
GetChar(cancellable bool) Event GetChar() Event
CancelGetChar()
Top() int Top() int
MaxX() int MaxX() int
@@ -889,19 +766,6 @@ type Window interface {
DrawBorder() DrawBorder()
DrawHBorder() DrawHBorder()
// DrawHSeparator draws an inline horizontal separator at `row` (relative to the
// window's top) using the color for `windowType`. The separator is conceptually
// the section's inner edge (e.g. the bottom border of an inline header), so the
// whole row including junctions carries the section's fg + bg. When useBottom is
// true the `bottom` horizontal char is used instead of `top`; for thinblock/block
// styles this keeps the thin line bonded to the list content on the opposite side.
DrawHSeparator(row int, windowType WindowType, useBottom bool)
// PaintSectionFrame overpaints the border cells around the rows [topContent,
// bottomContent] (inclusive, relative to the window's top) with the color for
// `windowType`. When edge is SectionEdgeTop / SectionEdgeBottom, the
// corresponding outer horizontal (+ corners) is also painted, letting the
// inline section claim that edge of the outer frame.
PaintSectionFrame(topContent, bottomContent int, windowType WindowType, edge SectionEdge)
Refresh() Refresh()
FinishFill() FinishFill()
@@ -916,7 +780,7 @@ type Window interface {
Print(text string) Print(text string)
CPrint(color ColorPair, text string) CPrint(color ColorPair, text string)
Fill(text string) FillReturn Fill(text string) FillReturn
CFill(fg Color, bg Color, ul Color, attr Attr, text string) FillReturn CFill(fg Color, bg Color, attr Attr, text string) FillReturn
LinkBegin(uri string, params string) LinkBegin(uri string, params string)
LinkEnd() LinkEnd()
Erase() Erase()
@@ -929,18 +793,16 @@ type FullscreenRenderer struct {
theme *ColorTheme theme *ColorTheme
mouse bool mouse bool
forceBlack bool forceBlack bool
tabstop int
prevDownTime time.Time prevDownTime time.Time
clicks [][2]int clicks [][2]int
showCursor bool showCursor bool
} }
func NewFullscreenRenderer(theme *ColorTheme, forceBlack bool, mouse bool, tabstop int) Renderer { func NewFullscreenRenderer(theme *ColorTheme, forceBlack bool, mouse bool) Renderer {
r := &FullscreenRenderer{ r := &FullscreenRenderer{
theme: theme, theme: theme,
mouse: mouse, mouse: mouse,
forceBlack: forceBlack, forceBlack: forceBlack,
tabstop: tabstop,
prevDownTime: time.Unix(0, 0), prevDownTime: time.Unix(0, 0),
clicks: [][2]int{}, clicks: [][2]int{},
showCursor: true} showCursor: true}
@@ -960,18 +822,16 @@ var (
ColDisabled ColorPair ColDisabled ColorPair
ColGhost ColorPair ColGhost ColorPair
ColMatch ColorPair ColMatch ColorPair
ColPointer ColorPair ColCursor ColorPair
ColPointerEmpty ColorPair ColCursorEmpty ColorPair
ColPointerEmptyChar ColorPair ColCursorEmptyChar ColorPair
ColAltPointerEmpty ColorPair
ColAltPointerEmptyChar ColorPair
ColMarker ColorPair ColMarker ColorPair
ColSelected ColorPair ColSelected ColorPair
ColSelectedMatch ColorPair ColSelectedMatch ColorPair
ColCurrent ColorPair ColCurrent ColorPair
ColCurrentMatch ColorPair ColCurrentMatch ColorPair
ColCurrentPointer ColorPair ColCurrentCursor ColorPair
ColCurrentPointerEmpty ColorPair ColCurrentCursorEmpty ColorPair
ColCurrentMarker ColorPair ColCurrentMarker ColorPair
ColCurrentSelectedEmpty ColorPair ColCurrentSelectedEmpty ColorPair
ColSpinner ColorPair ColSpinner ColorPair
@@ -1003,56 +863,50 @@ func init() {
undefined := ColorAttr{colUndefined, AttrUndefined} undefined := ColorAttr{colUndefined, AttrUndefined}
NoColorTheme = &ColorTheme{ NoColorTheme = &ColorTheme{
Colored: false, Colored: false,
// Root colors. Everything else is left undefined so that overriding a Input: defaultColor,
// root (e.g. --color bw,bg:blue) propagates to the derived colors, Fg: defaultColor,
// just like in the colored base themes. Bg: defaultColor,
Input: defaultColor, ListFg: defaultColor,
Fg: defaultColor, ListBg: 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, AltBg: undefined,
SelectedFg: undefined, SelectedFg: defaultColor,
SelectedBg: undefined, SelectedBg: defaultColor,
SelectedMatch: undefined, SelectedMatch: defaultColor,
DarkBg: defaultColor,
Prompt: defaultColor,
Match: defaultColor,
Current: undefined, Current: undefined,
CurrentMatch: undefined, CurrentMatch: undefined,
Spinner: defaultColor,
Info: defaultColor,
Cursor: defaultColor,
Marker: defaultColor,
Header: defaultColor,
Border: undefined, Border: undefined,
BorderLabel: defaultColor,
Ghost: undefined, Ghost: undefined,
Disabled: undefined, Disabled: defaultColor,
PreviewFg: undefined, PreviewFg: defaultColor,
PreviewBg: undefined, PreviewBg: defaultColor,
Gutter: undefined, Gutter: undefined,
AltGutter: undefined, PreviewBorder: defaultColor,
PreviewBorder: undefined, PreviewScrollbar: defaultColor,
PreviewScrollbar: undefined, PreviewLabel: defaultColor,
PreviewLabel: undefined, ListLabel: defaultColor,
ListLabel: undefined, ListBorder: defaultColor,
ListBorder: undefined, Separator: defaultColor,
Separator: undefined, Scrollbar: defaultColor,
Scrollbar: undefined, InputBg: defaultColor,
InputBg: undefined, InputBorder: defaultColor,
InputBorder: undefined, InputLabel: defaultColor,
InputLabel: undefined, HeaderBg: defaultColor,
HeaderBg: undefined, HeaderBorder: defaultColor,
HeaderBorder: undefined, HeaderLabel: defaultColor,
HeaderLabel: undefined, FooterBg: defaultColor,
FooterBg: undefined, FooterBorder: defaultColor,
FooterBorder: undefined, FooterLabel: defaultColor,
FooterLabel: undefined, GapLine: defaultColor,
GapLine: undefined,
Nth: undefined, Nth: undefined,
Nomatch: undefined, Nomatch: undefined,
} }
@@ -1075,7 +929,7 @@ func init() {
CurrentMatch: undefined, CurrentMatch: undefined,
Spinner: undefined, Spinner: undefined,
Info: undefined, Info: undefined,
Pointer: undefined, Cursor: undefined,
Marker: undefined, Marker: undefined,
Header: undefined, Header: undefined,
Footer: undefined, Footer: undefined,
@@ -1088,7 +942,6 @@ func init() {
PreviewFg: undefined, PreviewFg: undefined,
PreviewBg: undefined, PreviewBg: undefined,
Gutter: undefined, Gutter: undefined,
AltGutter: undefined,
PreviewBorder: undefined, PreviewBorder: undefined,
PreviewScrollbar: undefined, PreviewScrollbar: undefined,
PreviewLabel: undefined, PreviewLabel: undefined,
@@ -1126,7 +979,7 @@ func init() {
CurrentMatch: ColorAttr{colBrightGreen, AttrUndefined}, CurrentMatch: ColorAttr{colBrightGreen, AttrUndefined},
Spinner: ColorAttr{colGreen, AttrUndefined}, Spinner: ColorAttr{colGreen, AttrUndefined},
Info: ColorAttr{colYellow, AttrUndefined}, Info: ColorAttr{colYellow, AttrUndefined},
Pointer: ColorAttr{colRed, AttrUndefined}, Cursor: ColorAttr{colRed, AttrUndefined},
Marker: ColorAttr{colMagenta, AttrUndefined}, Marker: ColorAttr{colMagenta, AttrUndefined},
Header: ColorAttr{colCyan, AttrUndefined}, Header: ColorAttr{colCyan, AttrUndefined},
Footer: ColorAttr{colCyan, AttrUndefined}, Footer: ColorAttr{colCyan, AttrUndefined},
@@ -1137,7 +990,6 @@ func init() {
PreviewFg: undefined, PreviewFg: undefined,
PreviewBg: undefined, PreviewBg: undefined,
Gutter: undefined, Gutter: undefined,
AltGutter: undefined,
PreviewBorder: undefined, PreviewBorder: undefined,
PreviewScrollbar: undefined, PreviewScrollbar: undefined,
PreviewLabel: undefined, PreviewLabel: undefined,
@@ -1177,7 +1029,7 @@ func init() {
CurrentMatch: ColorAttr{151, AttrUndefined}, CurrentMatch: ColorAttr{151, AttrUndefined},
Spinner: ColorAttr{148, AttrUndefined}, Spinner: ColorAttr{148, AttrUndefined},
Info: ColorAttr{144, AttrUndefined}, Info: ColorAttr{144, AttrUndefined},
Pointer: ColorAttr{161, AttrUndefined}, Cursor: ColorAttr{161, AttrUndefined},
Marker: ColorAttr{168, AttrUndefined}, Marker: ColorAttr{168, AttrUndefined},
Header: ColorAttr{109, AttrUndefined}, Header: ColorAttr{109, AttrUndefined},
Footer: ColorAttr{109, AttrUndefined}, Footer: ColorAttr{109, AttrUndefined},
@@ -1188,7 +1040,6 @@ func init() {
PreviewFg: undefined, PreviewFg: undefined,
PreviewBg: undefined, PreviewBg: undefined,
Gutter: undefined, Gutter: undefined,
AltGutter: undefined,
PreviewBorder: undefined, PreviewBorder: undefined,
PreviewScrollbar: undefined, PreviewScrollbar: undefined,
PreviewLabel: undefined, PreviewLabel: undefined,
@@ -1228,7 +1079,7 @@ func init() {
CurrentMatch: ColorAttr{23, AttrUndefined}, CurrentMatch: ColorAttr{23, AttrUndefined},
Spinner: ColorAttr{65, AttrUndefined}, Spinner: ColorAttr{65, AttrUndefined},
Info: ColorAttr{101, AttrUndefined}, Info: ColorAttr{101, AttrUndefined},
Pointer: ColorAttr{161, AttrUndefined}, Cursor: ColorAttr{161, AttrUndefined},
Marker: ColorAttr{168, AttrUndefined}, Marker: ColorAttr{168, AttrUndefined},
Header: ColorAttr{31, AttrUndefined}, Header: ColorAttr{31, AttrUndefined},
Footer: ColorAttr{31, AttrUndefined}, Footer: ColorAttr{31, AttrUndefined},
@@ -1239,7 +1090,6 @@ func init() {
PreviewFg: undefined, PreviewFg: undefined,
PreviewBg: undefined, PreviewBg: undefined,
Gutter: undefined, Gutter: undefined,
AltGutter: undefined,
PreviewBorder: undefined, PreviewBorder: undefined,
PreviewScrollbar: undefined, PreviewScrollbar: undefined,
PreviewLabel: undefined, PreviewLabel: undefined,
@@ -1262,7 +1112,7 @@ func init() {
} }
} }
func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlack bool, hasInputWindow bool, hasHeaderWindow bool, headerInline bool, footerInline bool) { func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlack bool, hasInputWindow bool, hasHeaderWindow bool) {
if forceBlack { if forceBlack {
theme.Bg = ColorAttr{colBlack, AttrUndefined} theme.Bg = ColorAttr{colBlack, AttrUndefined}
} }
@@ -1279,7 +1129,7 @@ func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlac
theme.CurrentMatch = boldify(theme.CurrentMatch) theme.CurrentMatch = boldify(theme.CurrentMatch)
theme.Prompt = boldify(theme.Prompt) theme.Prompt = boldify(theme.Prompt)
theme.Input = boldify(theme.Input) theme.Input = boldify(theme.Input)
theme.Pointer = boldify(theme.Pointer) theme.Cursor = boldify(theme.Cursor)
theme.Spinner = boldify(theme.Spinner) theme.Spinner = boldify(theme.Spinner)
} }
@@ -1303,19 +1153,13 @@ func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlac
match.Attr = Underline match.Attr = Underline
} }
theme.Match = o(baseTheme.Match, match) theme.Match = o(baseTheme.Match, match)
// These colors are not defined in the base themes. // Inherit from 'fg', so that we don't have to write 'current-fg:dim'
// Resolve ListFg/ListBg early so Current and Selected can inherit from them.
theme.ListFg = o(theme.Fg, theme.ListFg)
theme.ListBg = o(theme.Bg, theme.ListBg)
// Inherit from 'list-fg', so that we don't have to write 'current-fg:dim'
// e.g. fzf --delimiter / --nth -1 --color fg:dim,nth:regular // e.g. fzf --delimiter / --nth -1 --color fg:dim,nth:regular
current := theme.Current current := theme.Current
if !baseTheme.Colored && current.IsUndefined() { if !baseTheme.Colored && current.IsUndefined() {
current.Attr |= Reverse current.Attr |= Reverse
} }
resolvedCurrent := o(baseTheme.Current, current) theme.Current = theme.Fg.Merge(o(baseTheme.Current, current))
theme.NthCurrentAttr = resolvedCurrent.Attr
theme.Current = theme.ListFg.Merge(resolvedCurrent)
currentMatch := theme.CurrentMatch currentMatch := theme.CurrentMatch
if !baseTheme.Colored && currentMatch.IsUndefined() { if !baseTheme.Colored && currentMatch.IsUndefined() {
currentMatch.Attr |= Reverse | Underline currentMatch.Attr |= Reverse | Underline
@@ -1323,7 +1167,7 @@ func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlac
theme.CurrentMatch = o(baseTheme.CurrentMatch, currentMatch) theme.CurrentMatch = o(baseTheme.CurrentMatch, currentMatch)
theme.Spinner = o(baseTheme.Spinner, theme.Spinner) theme.Spinner = o(baseTheme.Spinner, theme.Spinner)
theme.Info = o(baseTheme.Info, theme.Info) theme.Info = o(baseTheme.Info, theme.Info)
theme.Pointer = o(baseTheme.Pointer, theme.Pointer) theme.Cursor = o(baseTheme.Cursor, theme.Cursor)
theme.Marker = o(baseTheme.Marker, theme.Marker) theme.Marker = o(baseTheme.Marker, theme.Marker)
theme.Header = o(baseTheme.Header, theme.Header) theme.Header = o(baseTheme.Header, theme.Header)
theme.Footer = o(baseTheme.Footer, theme.Footer) theme.Footer = o(baseTheme.Footer, theme.Footer)
@@ -1340,8 +1184,10 @@ func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlac
scrollbarDefined := theme.Scrollbar != undefined scrollbarDefined := theme.Scrollbar != undefined
previewBorderDefined := theme.PreviewBorder != undefined previewBorderDefined := theme.PreviewBorder != undefined
theme.NthSelectedAttr = theme.SelectedFg.Attr // These colors are not defined in the base themes
theme.SelectedFg = theme.ListFg.Merge(theme.SelectedFg) theme.ListFg = o(theme.Fg, theme.ListFg)
theme.ListBg = o(theme.Bg, theme.ListBg)
theme.SelectedFg = o(theme.ListFg, theme.SelectedFg)
theme.SelectedBg = o(theme.ListBg, theme.SelectedBg) theme.SelectedBg = o(theme.ListBg, theme.SelectedBg)
theme.SelectedMatch = o(theme.Match, theme.SelectedMatch) theme.SelectedMatch = o(theme.Match, theme.SelectedMatch)
@@ -1361,7 +1207,6 @@ func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlac
gutter.Attr = Dim gutter.Attr = Dim
} }
theme.Gutter = o(theme.DarkBg, gutter) theme.Gutter = o(theme.DarkBg, gutter)
theme.AltGutter = o(theme.Gutter, theme.AltGutter)
theme.PreviewFg = o(theme.Fg, theme.PreviewFg) theme.PreviewFg = o(theme.Fg, theme.PreviewFg)
theme.PreviewBg = o(theme.Bg, theme.PreviewBg) theme.PreviewBg = o(theme.Bg, theme.PreviewBg)
theme.PreviewLabel = o(theme.BorderLabel, theme.PreviewLabel) theme.PreviewLabel = o(theme.BorderLabel, theme.PreviewLabel)
@@ -1396,22 +1241,11 @@ func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlac
} else { } else {
theme.HeaderBg = o(theme.Bg, theme.ListBg) theme.HeaderBg = o(theme.Bg, theme.ListBg)
} }
// Inline header/footer borders sit inside the list frame, so default their color theme.HeaderBorder = o(theme.Border, theme.HeaderBorder)
// to the list-border color when the user has not explicitly set it. The inline
// separator then matches the surrounding frame.
headerBorderFallback := theme.Border
if headerInline {
headerBorderFallback = theme.ListBorder
}
theme.HeaderBorder = o(headerBorderFallback, theme.HeaderBorder)
theme.HeaderLabel = o(theme.BorderLabel, theme.HeaderLabel) theme.HeaderLabel = o(theme.BorderLabel, theme.HeaderLabel)
theme.FooterBg = o(theme.Bg, theme.FooterBg) theme.FooterBg = o(theme.Bg, theme.FooterBg)
footerBorderFallback := theme.Border theme.FooterBorder = o(theme.Border, theme.FooterBorder)
if footerInline {
footerBorderFallback = theme.ListBorder
}
theme.FooterBorder = o(footerBorderFallback, theme.FooterBorder)
theme.FooterLabel = o(theme.BorderLabel, theme.FooterLabel) theme.FooterLabel = o(theme.BorderLabel, theme.FooterLabel)
if theme.Nomatch.IsUndefined() { if theme.Nomatch.IsUndefined() {
@@ -1426,7 +1260,7 @@ func initPalette(theme *ColorTheme) {
if fg.Color == colDefault && (fg.Attr&Reverse) > 0 { if fg.Color == colDefault && (fg.Attr&Reverse) > 0 {
bg.Color = colDefault bg.Color = colDefault
} }
return ColorPair{fg.Color, bg.Color, colDefault, fg.Attr} return ColorPair{fg.Color, bg.Color, fg.Attr}
} }
blank := theme.ListFg blank := theme.ListFg
blank.Attr = AttrRegular blank.Attr = AttrRegular
@@ -1439,11 +1273,9 @@ func initPalette(theme *ColorTheme) {
ColDisabled = pair(theme.Disabled, theme.InputBg) ColDisabled = pair(theme.Disabled, theme.InputBg)
ColMatch = pair(theme.Match, theme.ListBg) ColMatch = pair(theme.Match, theme.ListBg)
ColSelectedMatch = pair(theme.SelectedMatch, theme.SelectedBg) ColSelectedMatch = pair(theme.SelectedMatch, theme.SelectedBg)
ColPointer = pair(theme.Pointer, theme.Gutter) ColCursor = pair(theme.Cursor, theme.Gutter)
ColPointerEmpty = pair(blank, theme.Gutter) ColCursorEmpty = pair(blank, theme.Gutter)
ColPointerEmptyChar = pair(theme.Gutter, theme.ListBg) ColCursorEmptyChar = pair(theme.Gutter, theme.ListBg)
ColAltPointerEmpty = pair(blank, theme.AltGutter)
ColAltPointerEmptyChar = pair(theme.AltGutter, theme.ListBg)
if theme.SelectedBg.Color != theme.ListBg.Color { if theme.SelectedBg.Color != theme.ListBg.Color {
ColMarker = pair(theme.Marker, theme.SelectedBg) ColMarker = pair(theme.Marker, theme.SelectedBg)
} else { } else {
@@ -1451,8 +1283,8 @@ func initPalette(theme *ColorTheme) {
} }
ColCurrent = pair(theme.Current, theme.DarkBg) ColCurrent = pair(theme.Current, theme.DarkBg)
ColCurrentMatch = pair(theme.CurrentMatch, theme.DarkBg) ColCurrentMatch = pair(theme.CurrentMatch, theme.DarkBg)
ColCurrentPointer = pair(theme.Pointer, theme.DarkBg) ColCurrentCursor = pair(theme.Cursor, theme.DarkBg)
ColCurrentPointerEmpty = pair(blank, theme.DarkBg) ColCurrentCursorEmpty = pair(blank, theme.DarkBg)
ColCurrentMarker = pair(theme.Marker, theme.DarkBg) ColCurrentMarker = pair(theme.Marker, theme.DarkBg)
ColCurrentSelectedEmpty = pair(blank, theme.DarkBg) ColCurrentSelectedEmpty = pair(blank, theme.DarkBg)
ColSpinner = pair(theme.Spinner, theme.InputBg) ColSpinner = pair(theme.Spinner, theme.InputBg)
@@ -1482,46 +1314,3 @@ func initPalette(theme *ColorTheme) {
func runeWidth(r rune) int { func runeWidth(r rune) int {
return uniseg.StringWidth(string(r)) 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
}
-40
View File
@@ -2,46 +2,6 @@ package tui
import "testing" 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) { func TestHexToColor(t *testing.T) {
assert := func(expr string, r, g, b int) { assert := func(expr string, r, g, b int) {
color := HexToColor(expr) color := HexToColor(expr)
+2 -15
View File
@@ -187,7 +187,7 @@ func (chars *Chars) TrailingWhitespaces() int {
func (chars *Chars) TrimTrailingWhitespaces(maxIndex int) { func (chars *Chars) TrimTrailingWhitespaces(maxIndex int) {
whitespaces := chars.TrailingWhitespaces() whitespaces := chars.TrailingWhitespaces()
end := len(chars.slice) - whitespaces 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) { 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, wrapWord bool) ([][]rune, bool) { func (chars *Chars) Lines(multiLine bool, maxLines int, wrapCols int, wrapSignWidth int, tabstop int) ([][]rune, bool) {
text := make([]rune, chars.Length()) text := make([]rune, chars.Length())
copy(text, chars.ToRunes()) copy(text, chars.ToRunes())
@@ -307,19 +307,6 @@ func (chars *Chars) Lines(multiLine bool, maxLines int, wrapCols int, wrapSignWi
if overflowIdx == 0 { if overflowIdx == 0 {
overflowIdx = 1 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 { if len(wrapped) >= maxLines {
return wrapped, true return wrapped, true
} }
+1 -49
View File
@@ -51,7 +51,7 @@ func TestTrimLength(t *testing.T) {
func TestCharsLines(t *testing.T) { func TestCharsLines(t *testing.T) {
chars := ToChars([]byte("abcdef\n가나다\n\tdef")) chars := ToChars([]byte("abcdef\n가나다\n\tdef"))
check := func(multiLine bool, maxLines int, wrapCols int, wrapSignWidth int, tabstop int, expectedNumLines int, expectedOverflow bool) { 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, false) lines, overflow := chars.Lines(multiLine, maxLines, wrapCols, wrapSignWidth, tabstop)
fmt.Println(lines, overflow) fmt.Println(lines, overflow)
if len(lines) != expectedNumLines || overflow != expectedOverflow { if len(lines) != expectedNumLines || overflow != expectedOverflow {
t.Errorf("Invalid result: %d %v (expected %d %v)", len(lines), overflow, expectedNumLines, expectedOverflow) t.Errorf("Invalid result: %d %v (expected %d %v)", len(lines), overflow, expectedNumLines, expectedOverflow)
@@ -81,51 +81,3 @@ func TestCharsLines(t *testing.T) {
// With wrap sign (3 + 2) and no multi-line // With wrap sign (3 + 2) and no multi-line
check(false, 100, 3, 2, 1, 13, false) 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]))
}
}
+63 -10
View File
@@ -1,11 +1,11 @@
package util package util
import ( import (
"cmp"
"math" "math"
"os" "os"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/mattn/go-isatty" "github.com/mattn/go-isatty"
"github.com/rivo/uniseg" "github.com/rivo/uniseg"
@@ -18,13 +18,8 @@ func StringWidth(s string) int {
// RunesWidth returns runes width // RunesWidth returns runes width
func RunesWidth(runes []rune, prefixWidth int, tabstop int, limit int) (int, int) { 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 width := 0
gr := uniseg.NewGraphemes(str) gr := uniseg.NewGraphemes(string(runes))
idx := 0 idx := 0
for gr.Next() { for gr.Next() {
rs := gr.Runes() rs := gr.Runes()
@@ -60,8 +55,54 @@ func Truncate(input string, limit int) ([]rune, int) {
return runes, width return runes, width
} }
func Constrain[T cmp.Ordered](val, minimum, maximum T) T { // Max returns the largest integer
return max(min(val, maximum), minimum) 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 AsUint16(val int) uint16 { func AsUint16(val int) uint16 {
@@ -73,6 +114,18 @@ func AsUint16(val int) uint16 {
return uint16(val) 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 // IsTty returns true if the file is a terminal
func IsTty(file *os.File) bool { func IsTty(file *os.File) bool {
fd := file.Fd() fd := file.Fd()
@@ -144,7 +197,7 @@ func CompareVersions(v1, v2 string) int {
return n 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 var p1, p2 int
if i < len(parts1) { if i < len(parts1) {
p1 = atoi(parts1[i]) p1 = atoi(parts1[i])
+92
View File
@@ -4,8 +4,72 @@ import (
"math" "math"
"strings" "strings"
"testing" "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) { func TestConstrain(t *testing.T) {
if Constrain(-3, -1, 3) != -1 { if Constrain(-3, -1, 3) != -1 {
t.Error("Expected", -1) t.Error("Expected", -1)
@@ -19,6 +83,22 @@ 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) { func TestAsUint16(t *testing.T) {
if AsUint16(5) != 5 { if AsUint16(5) != 5 {
t.Error("Expected", 5) t.Error("Expected", 5)
@@ -40,6 +120,18 @@ 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) { func TestOnce(t *testing.T) {
o := Once(false) o := Once(false)
if o() { if o() {
+2 -3
View File
@@ -9,7 +9,6 @@ import (
"strings" "strings"
"syscall" "syscall"
"github.com/junegunn/go-shellwords"
"golang.org/x/sys/unix" "golang.org/x/sys/unix"
) )
@@ -21,8 +20,8 @@ type Executor struct {
func NewExecutor(withShell string) *Executor { func NewExecutor(withShell string) *Executor {
shell := os.Getenv("SHELL") shell := os.Getenv("SHELL")
args, err := shellwords.Parse(withShell) args := strings.Fields(withShell)
if err == nil && len(args) > 0 { if len(args) > 0 {
shell = args[0] shell = args[0]
args = args[1:] args = args[1:]
} else { } else {
+7 -44
View File
@@ -11,8 +11,6 @@ import (
"strings" "strings"
"sync/atomic" "sync/atomic"
"syscall" "syscall"
"golang.org/x/sys/windows"
) )
type shellType int type shellType int
@@ -21,7 +19,6 @@ const (
shellTypeUnknown shellType = iota shellTypeUnknown shellType = iota
shellTypeCmd shellTypeCmd
shellTypePowerShell shellTypePowerShell
shellTypePwsh
) )
var escapeRegex = regexp.MustCompile(`[&|<>()^%!"]`) var escapeRegex = regexp.MustCompile(`[&|<>()^%!"]`)
@@ -49,10 +46,7 @@ func NewExecutor(withShell string) *Executor {
} else if strings.HasPrefix(basename, "cmd") { } else if strings.HasPrefix(basename, "cmd") {
shellType = shellTypeCmd shellType = shellTypeCmd
args = []string{"/s/c"} args = []string{"/s/c"}
} else if strings.HasPrefix(basename, "pwsh") { } else if strings.HasPrefix(basename, "pwsh") || strings.HasPrefix(basename, "powershell") {
shellType = shellTypePwsh
args = []string{"-NoProfile", "-Command"}
} else if strings.HasPrefix(basename, "powershell") {
shellType = shellTypePowerShell shellType = shellTypePowerShell
args = []string{"-NoProfile", "-Command"} args = []string{"-NoProfile", "-Command"}
} else { } else {
@@ -62,12 +56,8 @@ func NewExecutor(withShell string) *Executor {
} }
// ExecCommand executes the given command with $SHELL // ExecCommand executes the given command with $SHELL
// // FIXME: setpgid is unused. We set it in the Unix implementation so that we
// On Windows, setpgid controls whether the spawned process is placed in a new // can kill preview process with its child processes at once.
// process group (so that it can be signaled independently, e.g. for previews).
// However, we only do this for "pwsh" and non-standard shells, because cmd.exe
// and Windows PowerShell ("powershell.exe") don't always exit on Ctrl-Break.
//
// NOTE: For "powershell", we should ideally set output encoding to UTF8, // NOTE: For "powershell", we should ideally set output encoding to UTF8,
// but it is left as is now because no adverse effect has been observed. // but it is left as is now because no adverse effect has been observed.
func (x *Executor) ExecCommand(command string, setpgid bool) *exec.Cmd { func (x *Executor) ExecCommand(command string, setpgid bool) *exec.Cmd {
@@ -83,31 +73,19 @@ func (x *Executor) ExecCommand(command string, setpgid bool) *exec.Cmd {
} }
x.shellPath.Store(shell) x.shellPath.Store(shell)
} }
var creationFlags uint32
// Set new process group for pwsh (PowerShell 7+) and unknown/posix-ish shells
if setpgid && (x.shellType == shellTypePwsh || x.shellType == shellTypeUnknown) {
creationFlags = windows.CREATE_NEW_PROCESS_GROUP
}
var cmd *exec.Cmd var cmd *exec.Cmd
if x.shellType == shellTypeCmd { if x.shellType == shellTypeCmd {
cmd = exec.Command(shell) cmd = exec.Command(shell)
cmd.SysProcAttr = &syscall.SysProcAttr{ cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: false, HideWindow: false,
CmdLine: fmt.Sprintf(`%s "%s"`, strings.Join(x.args, " "), command), CmdLine: fmt.Sprintf(`%s "%s"`, strings.Join(x.args, " "), command),
CreationFlags: creationFlags, CreationFlags: 0,
} }
} else { } else {
args := x.args cmd = exec.Command(shell, append(x.args, command)...)
if setpgid && x.shellType == shellTypePwsh {
// pwsh needs -NonInteractive flag to exit on Ctrl-Break
args = append([]string{"-NonInteractive"}, x.args...)
}
cmd = exec.Command(shell, append(args, command)...)
cmd.SysProcAttr = &syscall.SysProcAttr{ cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: false, HideWindow: false,
CreationFlags: creationFlags, CreationFlags: 0,
} }
} }
return cmd return cmd
@@ -178,7 +156,7 @@ func (x *Executor) QuoteEntry(entry string) string {
fd -H --no-ignore -td -d 4 | fzf --preview ".\eza.exe --color=always --tree --level=3 --icons=always {}" --with-shell "powershell -NoProfile -Command" fd -H --no-ignore -td -d 4 | fzf --preview ".\eza.exe --color=always --tree --level=3 --icons=always {}" --with-shell "powershell -NoProfile -Command"
*/ */
return escapeArg(entry) return escapeArg(entry)
case shellTypePowerShell, shellTypePwsh: case shellTypePowerShell:
escaped := strings.ReplaceAll(entry, `"`, `\"`) escaped := strings.ReplaceAll(entry, `"`, `\"`)
return "'" + strings.ReplaceAll(escaped, "'", "''") + "'" return "'" + strings.ReplaceAll(escaped, "'", "''") + "'"
default: default:
@@ -188,21 +166,6 @@ func (x *Executor) QuoteEntry(entry string) string {
// KillCommand kills the process for the given command // KillCommand kills the process for the given command
func KillCommand(cmd *exec.Cmd) error { func KillCommand(cmd *exec.Cmd) error {
// Safely handle nil command or process.
if cmd == nil || cmd.Process == nil {
return nil
}
// If it has its own process group, we can send it Ctrl-Break
if cmd.SysProcAttr != nil && cmd.SysProcAttr.CreationFlags&windows.CREATE_NEW_PROCESS_GROUP != 0 {
if err := windows.GenerateConsoleCtrlEvent(windows.CTRL_BREAK_EVENT, uint32(cmd.Process.Pid)); err == nil {
return nil
}
}
// If it's the same process group, or if sending the console control event
// fails (e.g., no console, different console, or process already exited),
// fall back to a standard kill. This probably won't *help* if there's I/O
// going on, because Wait() will still hang until the I/O finishes unless we
// hard-kill the entire process group. But it doesn't hurt to try!
return cmd.Process.Kill() return cmd.Process.Kill()
} }
-66
View File
@@ -1,66 +0,0 @@
package fzf
import (
"os/exec"
"github.com/junegunn/fzf/src/tui"
)
func runZellij(args []string, opts *Options) (int, error) {
// Use the native Zellij border by default, consistent with tmux, so that
// the pane can be moved and resized with the mouse. Set before
// popupArgStr so that it does not inject an fzf border. fzf draws its own
// border instead when a border style is explicitly specified.
if nativeBorder(opts) {
opts.Tmux.border = true
}
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")
} else {
// Set --border-label as the name of the pane, displayed on the
// native border. The label is left to fzf when it draws its own
// border with the label on it (border-native with an explicit
// --border style). Empty otherwise, to override the default name
// (the running command). Passed as a single argument in the
// --name=label form; the detached form fails to parse when the
// label starts with a hyphen. No escaping is needed beyond
// stripping ANSI sequences fzf would otherwise render itself.
// --border-label-pos is ignored.
label := ""
if opts.BorderShape == tui.BorderUndefined || opts.BorderShape == tui.BorderLine ||
opts.BorderShape == tui.BorderNone {
label, _, _ = extractColor(opts.BorderLabel.label, nil, nil)
}
zellijArgs = append(zellijArgs, "--name="+label)
}
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)
}
-16
View File
@@ -1,16 +0,0 @@
# 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"
+7 -165
View File
@@ -11,7 +11,6 @@ require 'net/http'
require 'json' require 'json'
TEMPLATE = File.read(File.expand_path('common.sh', __dir__)) TEMPLATE = File.read(File.expand_path('common.sh', __dir__))
FISH_TEMPLATE = File.read(File.expand_path('common.fish', __dir__))
UNSETS = %w[ UNSETS = %w[
FZF_DEFAULT_COMMAND FZF_DEFAULT_OPTS FZF_DEFAULT_COMMAND FZF_DEFAULT_OPTS
FZF_TMUX FZF_TMUX_OPTS FZF_TMUX FZF_TMUX_OPTS
@@ -67,48 +66,7 @@ class Shell
end end
def fish def fish
@fish ||= "unset #{UNSETS.join(' ')}; rm -f ~/.local/share/fish/fzf_test_history; FZF_DEFAULT_OPTS=\"--no-scrollbar --pointer '>' --marker '>'\" fish_history=fzf_test 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 end
end end
@@ -117,31 +75,12 @@ class Tmux
attr_reader :win attr_reader :win
def initialize(shell = :bash) def initialize(shell = :bash)
@shell = shell
@win = go(%W[new-window -d -P -F #I #{Shell.send(shell)}]).first @win = go(%W[new-window -d -P -F #I #{Shell.send(shell)}]).first
go(%W[set-window-option -t #{@win} pane-base-index 0]) go(%W[set-window-option -t #{@win} pane-base-index 0])
if shell == :fish return unless 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
retry send_keys 'function fish_prompt; end; clear', :Enter
end self.until(&:empty?)
send_keys 'clear', :Enter
self.until(&:empty?)
end
end end
def kill def kill
@@ -156,102 +95,14 @@ class Tmux
go(%W[send-keys -t #{win}] + args.map(&:to_s)) go(%W[send-keys -t #{win}] + args.map(&:to_s))
end 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) def paste(str)
system('tmux', 'setb', str, ';', 'pasteb', '-t', win, ';', 'send-keys', '-t', win, 'Enter') system('tmux', 'setb', str, ';', 'pasteb', '-t', win, ';', 'send-keys', '-t', win, 'Enter')
end end
# Paste with bracketed paste control codes so fzf sees
# bracketed-paste-begin/end around the content
def paste_bracketed(str)
system('tmux', 'setb', str, ';', 'pasteb', '-p', '-t', win)
end
def capture def capture
go(%W[capture-pane -p -J -t #{win}]).map(&:rstrip).reverse.drop_while(&:empty?).reverse go(%W[capture-pane -p -J -t #{win}]).map(&:rstrip).reverse.drop_while(&:empty?).reverse
end 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) def until(refresh = false, timeout: DEFAULT_TIMEOUT)
lines = nil lines = nil
begin begin
@@ -299,19 +150,10 @@ class Tmux
def prepare def prepare
tries = 0 tries = 0
begin begin
if @shell == :nushell self.until(true) do |lines|
message = "Prepare[#{tries}]" message = "Prepare[#{tries}]"
send_keys 'C-u', 'C-l' send_keys ' ', 'C-u', :Enter, message, :Left, :Right
sleep(0.2) lines[-1] == message
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 end
rescue Minitest::Assertion rescue Minitest::Assertion
(tries += 1) < 5 ? retry : raise (tries += 1) < 5 ? retry : raise

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