mirror of
https://github.com/junegunn/fzf
synced 2026-06-10 10:13:17 +00:00
Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f5fbfd848e | |||
| dea72834ed | |||
| abee152255 | |||
| bf114bcc21 | |||
| 838ac7554b | |||
| ae78a5c56d | |||
| 7d647c70c2 | |||
| 6bd17f8f9a | |||
| 249a6df4a4 | |||
| a50619388d | |||
| 5ef8dea36e | |||
| 845752f305 | |||
| 9a61a1457d | |||
| dfcacb443d | |||
| 5412f39b84 | |||
| 07c5cd4185 | |||
| ce4bef7595 | |||
| 25868a62f7 | |||
| 7963a2c658 | |||
| 4b23aa45a8 | |||
| 3953d1c649 | |||
| 5e137613d3 | |||
| a24deef77b | |||
| 1b6e17ca39 | |||
| 94f6daa61c | |||
| 02594f8dbc | |||
| f81cb1939c | |||
| 290b18d9fe | |||
| ccedd064ca | |||
| d4352a013d | |||
| 665bef56ea | |||
| e912cdb3e4 | |||
| de1fca99d5 | |||
| 677e854850 | |||
| 67319aed0b | |||
| 367177d911 | |||
| 5819e5ff2f | |||
| fcc3c6acce | |||
| e0d081906f | |||
| 263eb4732f | |||
| b4a86a9c8a | |||
| 6fefe02546 | |||
| f783582561 | |||
| af42fde089 | |||
| 27dab2422e | |||
| 56be41218c | |||
| 7782da6c00 | |||
| 43f0508dd2 | |||
| 1986d101e0 | |||
| dd7a081b93 | |||
| d247284bc3 | |||
| 6f3e5de150 | |||
| 0142fe9d04 | |||
| 251b08b831 | |||
| 01567b4f06 | |||
| d754cfab87 | |||
| ef6eba1b89 | |||
| faa6a7e9f6 | |||
| 8b66489987 | |||
| 780a624ed7 | |||
| 1ad7c617b1 | |||
| 06e70570e2 | |||
| bd4a18d0a9 | |||
| 1b0d448c6e | |||
| f584cf38f7 | |||
| 0eb2ae9f8b | |||
| 1831500018 |
@@ -11,4 +11,4 @@ jobs:
|
||||
- name: 'Checkout Repository'
|
||||
uses: actions/checkout@v5
|
||||
- name: 'Dependency Review'
|
||||
uses: actions/dependency-review-action@v4
|
||||
uses: actions/dependency-review-action@v5
|
||||
|
||||
@@ -28,12 +28,17 @@ jobs:
|
||||
go-version: "1.23"
|
||||
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@3ff19f5e2baf30647122352b96108b1fbe250c64 # v1
|
||||
uses: ruby/setup-ruby@97ecb7b512899eb71ab1bf2310a624c6f1589ac6 # v1
|
||||
with:
|
||||
ruby-version: 3.4.6
|
||||
|
||||
- name: Install packages
|
||||
run: sudo apt-get install --yes zsh fish tmux shfmt
|
||||
run: |
|
||||
sudo install -d -m 0755 /etc/apt/keyrings
|
||||
wget -qO- https://apt.fury.io/nushell/gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/fury-nushell.gpg
|
||||
echo "deb [signed-by=/etc/apt/keyrings/fury-nushell.gpg] https://apt.fury.io/nushell/ /" | sudo tee /etc/apt/sources.list.d/fury-nushell.list
|
||||
sudo apt-get update
|
||||
sudo apt-get install --yes zsh fish tmux shfmt nushell
|
||||
|
||||
- name: Install Ruby gems
|
||||
run: bundle install
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
go-version: "1.23"
|
||||
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@3ff19f5e2baf30647122352b96108b1fbe250c64 # v1
|
||||
uses: ruby/setup-ruby@97ecb7b512899eb71ab1bf2310a624c6f1589ac6 # v1
|
||||
with:
|
||||
ruby-version: 3.0.0
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to validate (e.g. 0.73.0).'
|
||||
type: string
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: macos-latest
|
||||
environment: release
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: stable
|
||||
|
||||
- name: Determine version
|
||||
id: ver
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
v=${GITHUB_REF_NAME#v}
|
||||
else
|
||||
v='${{ inputs.version }}'
|
||||
fi
|
||||
echo "version=$v" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved version: '$v'"
|
||||
|
||||
- name: Verify version consistency
|
||||
run: |
|
||||
set -e
|
||||
V='${{ steps.ver.outputs.version }}'
|
||||
R=$(echo "$V" | sed 's/\./\\./g')
|
||||
grep -q "^${R}$" CHANGELOG.md
|
||||
grep -qF "\"fzf ${V}\"" man/man1/fzf.1
|
||||
grep -qF "\"fzf ${V}\"" man/man1/fzf-tmux.1
|
||||
grep -qF "${V}" install
|
||||
grep -qF "${V}" install.ps1
|
||||
|
||||
- name: Extract release notes
|
||||
run: |
|
||||
set -e
|
||||
mkdir -p tmp
|
||||
V='${{ steps.ver.outputs.version }}'
|
||||
R=$(echo "$V" | sed 's/\./\\./g')
|
||||
sed -n "/^${R}$/,/^[0-9]/p" CHANGELOG.md \
|
||||
| tail -r | sed '1,/^ *$/d' | tail -r | sed '1,2d' \
|
||||
| tee tmp/release-note
|
||||
|
||||
- name: Run goreleaser
|
||||
uses: goreleaser/goreleaser-action@v7
|
||||
with:
|
||||
version: latest
|
||||
args: >-
|
||||
${{ github.event_name == 'push'
|
||||
&& 'release --clean --release-notes tmp/release-note'
|
||||
|| 'release --snapshot --clean --skip=publish' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.RELEASE_PAT }}
|
||||
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
|
||||
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
|
||||
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
|
||||
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
|
||||
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
|
||||
@@ -1,24 +0,0 @@
|
||||
---
|
||||
name: Generate Sponsors README
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: 0 15 * * 6
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout 🛎️
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Generate Sponsors 💖
|
||||
uses: JamesIves/github-sponsors-readme-action@2fd9142e765f755780202122261dc85e78459405 # v1
|
||||
with:
|
||||
token: ${{ secrets.SPONSORS_TOKEN }}
|
||||
file: 'README.md'
|
||||
|
||||
- name: Deploy to GitHub Pages 🚀
|
||||
uses: JamesIves/github-pages-deploy-action@d92aa235d04922e8f08b40ce78cc5442fcfbfa2f # v4
|
||||
with:
|
||||
branch: master
|
||||
folder: '.'
|
||||
@@ -2,6 +2,12 @@ name: Publish to Winget
|
||||
on:
|
||||
release:
|
||||
types: [released]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release-tag:
|
||||
description: 'Release tag to submit (e.g. v0.73.1)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
@@ -10,5 +16,6 @@ jobs:
|
||||
- uses: vedantmgoyal2009/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2
|
||||
with:
|
||||
identifier: junegunn.fzf
|
||||
release-tag: ${{ inputs.release-tag || github.event.release.tag_name }}
|
||||
installers-regex: '-windows_(armv7|arm64|amd64)\.zip$'
|
||||
token: ${{ secrets.WINGET_TOKEN }}
|
||||
|
||||
+7
-7
@@ -309,16 +309,16 @@ I know it's a lot to digest, let's try to break down the code.
|
||||
available color options.
|
||||
- The value of `--preview-window` option consists of 5 components delimited
|
||||
by `,`
|
||||
1. `up` — Position of the preview window
|
||||
1. `60%` — Size of the preview window
|
||||
1. `border-bottom` — Preview window border only on the bottom side
|
||||
1. `+{2}+3/3` — Scroll offset of the preview contents
|
||||
1. `~3` — Fixed header
|
||||
1. `up` -- Position of the preview window
|
||||
1. `60%` -- Size of the preview window
|
||||
1. `border-bottom` -- Preview window border only on the bottom side
|
||||
1. `+{2}+3/3` -- Scroll offset of the preview contents
|
||||
1. `~3` -- Fixed header
|
||||
- Let's break down the latter two. We want to display the bat output in the
|
||||
preview window with a certain scroll offset so that the matching line is
|
||||
positioned near the center of the preview window.
|
||||
- `+{2}` — The base offset is extracted from the second token
|
||||
- `+3` — We add 3 lines to the base offset to compensate for the header
|
||||
- `+{2}` -- The base offset is extracted from the second token
|
||||
- `+3` -- We add 3 lines to the base offset to compensate for the header
|
||||
part of `bat` output
|
||||
- ```
|
||||
───────┬──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,6 +1,67 @@
|
||||
CHANGELOG
|
||||
=========
|
||||
|
||||
0.73.1
|
||||
------
|
||||
- Bug fixes
|
||||
- Skip `$FZF_CURRENT_ITEM` export when the item contains a NUL byte; `exec(2)` rejects the env, breaking preview and other child commands (#4806)
|
||||
- Fixed O(n^2) HTTP body accumulation in `--listen`; a single ~390 KB request could block the single-threaded server for ~8 s (Michal Majchrowicz, Marcin Wyczechowski, AFINE Team)
|
||||
|
||||
0.73.0
|
||||
------
|
||||
_Release highlights: https://junegunn.github.io/fzf/releases/0.73.0/_
|
||||
|
||||
- Nushell integration via `fzf --nushell` and the installer (#4630) (@sim590)
|
||||
- New `--preview-window=next` position that places the preview adjacent to the input section, on the list side: above the input in the default layout, below it in `--layout=reverse` (#4798)
|
||||
- Timer-driven `every(N)` event for `--bind`, where `N` is seconds
|
||||
- Added `$FZF_IDLE_TIME` (whole seconds) and `$FZF_IDLE_TIME_MS` (milliseconds), holding the elapsed time since the last user activity
|
||||
- Pair with `every(N)` to build idle-based behavior such as auto-accept or auto-quit (#1211)
|
||||
```sh
|
||||
# Live process list; --track --id-nth 2 keeps the cursor on the same PID across reloads
|
||||
fzf --header-lines 1 --track --id-nth 2 --bind 'start,every(2):reload-sync:ps -ef'
|
||||
|
||||
# Auto-accept after 10 seconds of inactivity, with a countdown in the footer after 5s
|
||||
fzf --bind 'every(1):bg-transform:
|
||||
if [[ $FZF_IDLE_TIME -lt 5 ]]; then echo change-footer:
|
||||
elif [[ $FZF_IDLE_TIME -lt 10 ]]; then echo "change-footer:auto-accept in $((10 - FZF_IDLE_TIME))s"
|
||||
else echo accept
|
||||
fi'
|
||||
```
|
||||
- Added `$FZF_CURRENT_ITEM` for shells where quoting `{}` is awkward (#4802)
|
||||
- Bug fixes
|
||||
- Scoring: non-word characters at the start of input or after a delimiter now receive the same boundary bonus as word characters (#4795)
|
||||
- `change-preview-window` no longer resets `wrap` / `wrap-word` state set via `toggle-preview-wrap` / `toggle-preview-wrap-word` (#4791)
|
||||
- Stripped UTF-8-encoded C1 control characters from rendered items to prevent terminal control-sequence injection
|
||||
- Fixed integer-overflow panic in `FuzzyMatchV2` on 32-bit builds (Michal Majchrowicz, Marcin Wyczechowski, AFINE Team)
|
||||
- Fixed `bg-transform` `reload` / `exclude` payloads being dropped
|
||||
- Fixed rendering glitch with preview window on the left combined with footer
|
||||
|
||||
0.72.0
|
||||
------
|
||||
_Release highlights: https://junegunn.github.io/fzf/releases/0.72.0/_
|
||||
|
||||
- `--header-border`, `--header-lines-border`, and `--footer-border` now accept a new `inline` style that embeds the section inside the list frame, separated from the list content by a horizontal line. When the list border has side segments, the separator joins them as T-junctions.
|
||||
- Requires a `--list-border` shape that has both top and bottom segments (`rounded`, `sharp`, `bold`, `double`, `block`, `thinblock`, or `horizontal`); falls back to `line` otherwise. `horizontal` has no side borders, so the separator is drawn without T-junction endpoints.
|
||||
- Sections stack. Example combining all three:
|
||||
```sh
|
||||
ps -ef | fzf --reverse --style full \
|
||||
--header 'Select a process' --header-lines 1 \
|
||||
--bind 'load:transform-footer:echo $FZF_TOTAL_COUNT processes' \
|
||||
--header-border dashed --header-first \
|
||||
--header-lines-border inline --footer-border inline
|
||||
```
|
||||
- `--header-label` and `--footer-label` render on their respective separator row.
|
||||
- The separator inherits `--color list-border` when the section's own border color is not explicitly set.
|
||||
- `inline` takes precedence over `--header-first`: the inline section stays inside the list frame. `--header-border=inline` requires `--header-lines-border` to be `inline` or unset.
|
||||
- New `dashed` border style with dashed edges (`╶` / `┆`) and rounded corners.
|
||||
- `--border=dashed`, `--list-border=dashed`, etc.
|
||||
- Works with inline sections (T-junctions render correctly).
|
||||
- [vim] Move and resize popup window when detecting `VimResized` event (#4778) (@Vulcalien)
|
||||
- Bug fixes
|
||||
- Fixed gutter display in `--style=minimal`
|
||||
- Fixed arrow keys / Home / End without modifiers being ignored under the kitty keyboard protocol (#4776) (@TymekDev)
|
||||
- bash: Persist history deletion when `histappend` is on (#4764)
|
||||
|
||||
0.71.0
|
||||
------
|
||||
_Release highlights: https://junegunn.github.io/fzf/releases/0.71.0/_
|
||||
|
||||
+11
-1
@@ -1,5 +1,15 @@
|
||||
FROM rubylang/ruby:3.4.1-noble
|
||||
RUN apt-get update -y && apt install -y git make golang zsh fish tmux
|
||||
RUN apt-get update && apt-get install -y git make golang zsh fish tmux
|
||||
|
||||
# https://www.nushell.sh/book/installation.html
|
||||
RUN <<EOF
|
||||
set -ex
|
||||
apt-get install -y wget gnupg
|
||||
wget -qO- https://apt.fury.io/nushell/gpg.key | gpg --dearmor -o /etc/apt/keyrings/fury-nushell.gpg
|
||||
echo "deb [signed-by=/etc/apt/keyrings/fury-nushell.gpg] https://apt.fury.io/nushell/ /" | tee /etc/apt/sources.list.d/fury-nushell.list
|
||||
apt-get update
|
||||
apt-get install -y nushell
|
||||
EOF
|
||||
RUN gem install --no-document -v 5.22.3 minitest
|
||||
RUN echo '. /usr/share/bash-completion/completions/git' >> ~/.bashrc
|
||||
RUN echo '. ~/.bashrc' >> ~/.bash_profile
|
||||
|
||||
@@ -53,6 +53,8 @@ ifeq ($(UNAME_M),x86_64)
|
||||
BINARY := $(BINARY64)
|
||||
else ifeq ($(UNAME_M),amd64)
|
||||
BINARY := $(BINARY64)
|
||||
else ifeq ($(UNAME_M),i86pc)
|
||||
BINARY := $(BINARY64)
|
||||
else ifeq ($(UNAME_M),s390x)
|
||||
BINARY := $(BINARYS390)
|
||||
else ifeq ($(UNAME_M),i686)
|
||||
@@ -115,6 +117,19 @@ generate:
|
||||
build:
|
||||
goreleaser build --clean --snapshot --skip=post-hooks
|
||||
|
||||
prerelease:
|
||||
# Check if version numbers are properly updated
|
||||
grep -q ^$(VERSION_REGEX)$$ CHANGELOG.md
|
||||
grep -qF '"fzf $(VERSION_TRIM)"' man/man1/fzf.1
|
||||
grep -qF '"fzf $(VERSION_TRIM)"' man/man1/fzf-tmux.1
|
||||
grep -qF $(VERSION) install
|
||||
grep -qF $(VERSION) install.ps1
|
||||
@echo "OK: all files consistent at $(VERSION)"
|
||||
|
||||
tag: prerelease
|
||||
git tag -s v$(VERSION) -m v$(VERSION)
|
||||
git push origin v$(VERSION)
|
||||
|
||||
release:
|
||||
# Make sure that the tests pass and the build works
|
||||
TAGS=tcell make test
|
||||
@@ -204,4 +219,4 @@ update:
|
||||
$(GO) get -u
|
||||
$(GO) mod tidy
|
||||
|
||||
.PHONY: all generate build release test itest bench lint install clean docker docker-test update fmt
|
||||
.PHONY: all generate build prerelease tag release test itest bench lint install clean docker docker-test update fmt
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
Release process
|
||||
===============
|
||||
|
||||
Building, signing, notarizing, and publishing is handled by
|
||||
[`.github/workflows/release.yml`](.github/workflows/release.yml),
|
||||
triggered by a tag push.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Update version in the following files and commit on `master`:
|
||||
- `CHANGELOG.md`
|
||||
- `main.go`
|
||||
- `install`
|
||||
- `install.ps1`
|
||||
- `man/man1/fzf.1`
|
||||
- `man/man1/fzf-tmux.1`
|
||||
|
||||
2. Verify file consistency, sign the tag, and push the tag.
|
||||
|
||||
```sh
|
||||
make tag VERSION=0.73.1
|
||||
```
|
||||
|
||||
`make tag` runs `prerelease` first (checks that the version
|
||||
appears in CHANGELOG.md, both man pages, install, and install.ps1)
|
||||
and only signs + pushes the tag if the checks pass.
|
||||
|
||||
Only the tag is pushed; `master` on origin still points to the
|
||||
old version, so `/master/install` keeps resolving against existing
|
||||
binaries during the publish window.
|
||||
|
||||
3. The workflow fires on the tag push and pauses on the `release`
|
||||
environment gate. Approve it in the Actions tab to release.
|
||||
|
||||
4. After the GitHub release is published, fast-forward `master`:
|
||||
|
||||
```sh
|
||||
git push origin master
|
||||
```
|
||||
|
||||
## Testing the workflow
|
||||
|
||||
To exercise the workflow without firing a real release:
|
||||
|
||||
1. Actions tab -> **Release** -> **Run workflow**.
|
||||
2. Pick a branch and enter the version currently on that branch
|
||||
(the version-consistency check requires the input to match the
|
||||
files in the checked-out tree).
|
||||
3. Approve the `release` environment gate when prompted.
|
||||
4. Goreleaser runs with `--snapshot --skip=publish`. Signing and
|
||||
notarization run; only the GitHub release upload is skipped.
|
||||
|
||||
Use this to validate the workflow YAML, version-extraction logic,
|
||||
the macOS runner setup, and the signing/notarization credentials.
|
||||
@@ -4,7 +4,7 @@ require (
|
||||
github.com/charlievieth/fastwalk v1.0.14
|
||||
github.com/gdamore/tcell/v2 v2.9.0
|
||||
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741
|
||||
github.com/mattn/go-isatty v0.0.20
|
||||
github.com/mattn/go-isatty v0.0.22
|
||||
github.com/rivo/uniseg v0.4.7
|
||||
golang.org/x/sys v0.35.0
|
||||
golang.org/x/term v0.34.0
|
||||
|
||||
@@ -8,8 +8,8 @@ github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 h1:7dYDtfMD
|
||||
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741/go.mod h1:6EILKtGpo5t+KLb85LNZLAF6P9LKp78hJI80PXMcn3c=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
@@ -33,7 +33,6 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
set -u
|
||||
|
||||
version=0.71.0
|
||||
version=0.73.1
|
||||
auto_completion=
|
||||
key_bindings=
|
||||
update_config=2
|
||||
shells="bash zsh fish"
|
||||
shells="bash zsh fish nushell"
|
||||
prefix='~/.fzf'
|
||||
prefix_expand=~/.fzf
|
||||
fish_dir=${XDG_CONFIG_HOME:-$HOME/.config}/fish
|
||||
@@ -27,6 +27,7 @@ usage: $0 [OPTIONS]
|
||||
--no-bash Do not set up bash configuration
|
||||
--no-zsh Do not set up zsh configuration
|
||||
--no-fish Do not set up fish configuration
|
||||
--no-nushell Do not set up nushell configuration
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -56,6 +57,7 @@ for opt in "$@"; do
|
||||
--no-bash) shells=${shells/bash/} ;;
|
||||
--no-zsh) shells=${shells/zsh/} ;;
|
||||
--no-fish) shells=${shells/fish/} ;;
|
||||
--no-nushell) shells=${shells/nushell/} ;;
|
||||
*)
|
||||
echo "unknown option: $opt"
|
||||
help
|
||||
@@ -224,12 +226,14 @@ fi
|
||||
[[ $* =~ "--bin" ]] && exit 0
|
||||
|
||||
for s in $shells; do
|
||||
if ! command -v "$s" > /dev/null; then
|
||||
bin=$s
|
||||
[[ $s == nushell ]] && bin=nu
|
||||
if ! command -v "$bin" > /dev/null; then
|
||||
shells=${shells/$s/}
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${#shells} -lt 3 ]]; then
|
||||
if [[ -z ${shells// /} ]]; then
|
||||
echo "No shell configuration to be updated."
|
||||
exit 0
|
||||
fi
|
||||
@@ -248,6 +252,7 @@ fi
|
||||
|
||||
echo
|
||||
for shell in $shells; do
|
||||
[[ $shell == nushell ]] && continue
|
||||
fzf_completion="source \"$fzf_base/shell/completion.${shell}\""
|
||||
fzf_key_bindings="source \"$fzf_base/shell/key-bindings.${shell}\""
|
||||
[[ $shell == fish ]] && continue
|
||||
@@ -368,6 +373,7 @@ fi
|
||||
echo
|
||||
for shell in $shells; do
|
||||
[[ $shell == fish ]] && continue
|
||||
[[ $shell == nushell ]] && continue
|
||||
[ $shell = zsh ] && dest=${ZDOTDIR:-~}/.zshrc || dest=~/.bashrc
|
||||
append_line $update_config "[ -f ${prefix}.${shell} ] && source ${prefix}.${shell}" "$dest" "${prefix}.${shell}"
|
||||
done
|
||||
@@ -436,6 +442,25 @@ if [[ $shells =~ fish ]]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ $shells =~ nushell ]]; then
|
||||
if [[ $key_bindings -eq 1 || $auto_completion -eq 1 ]]; then
|
||||
echo "Setting up Nushell integration ..."
|
||||
nushell_autoload_dir=$(nu -c '$nu.user-autoload-dirs | first')
|
||||
mkdir -p "$nushell_autoload_dir"
|
||||
dest="$nushell_autoload_dir/_fzf_integration.nu"
|
||||
echo -n " Generate $dest ... "
|
||||
if [[ $key_bindings -eq 1 && $auto_completion -eq 1 ]]; then
|
||||
"$fzf_base"/bin/fzf --nushell > "$dest"
|
||||
elif [[ $key_bindings -eq 1 ]]; then
|
||||
cp "$fzf_base/shell/key-bindings.nu" "$dest"
|
||||
else
|
||||
cp "$fzf_base/shell/completion.nu" "$dest"
|
||||
fi
|
||||
echo "OK"
|
||||
echo
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ $update_config -eq 1 ]; then
|
||||
echo 'Finished. Restart your shell or reload config file.'
|
||||
if [[ $shells =~ bash ]]; then
|
||||
@@ -445,6 +470,7 @@ if [ $update_config -eq 1 ]; then
|
||||
fi
|
||||
[[ $shells =~ zsh ]] && echo " source ${ZDOTDIR:-~}/.zshrc # zsh"
|
||||
[[ $shells =~ fish && $lno_func -ne 0 ]] && echo ' fish_user_key_bindings # fish'
|
||||
[[ $shells =~ nushell ]] && echo ' # nushell: files are loaded automatically from autoload directory'
|
||||
echo
|
||||
echo 'Use uninstall script to remove fzf.'
|
||||
echo
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
$version="0.71.0"
|
||||
$version="0.73.1"
|
||||
|
||||
$fzf_base=Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/junegunn/fzf/src/protector"
|
||||
)
|
||||
|
||||
var version = "0.71"
|
||||
var version = "0.73"
|
||||
var revision = "devel"
|
||||
|
||||
//go:embed shell/key-bindings.bash
|
||||
@@ -29,6 +29,12 @@ var zshCompletion []byte
|
||||
//go:embed shell/key-bindings.fish
|
||||
var fishKeyBindings []byte
|
||||
|
||||
//go:embed shell/key-bindings.nu
|
||||
var nushellKeyBindings []byte
|
||||
|
||||
//go:embed shell/completion.nu
|
||||
var nushellCompletion []byte
|
||||
|
||||
//go:embed shell/completion.fish
|
||||
var fishCompletion []byte
|
||||
|
||||
@@ -71,6 +77,11 @@ func main() {
|
||||
printScript("completion.fish", fishCompletion)
|
||||
return
|
||||
}
|
||||
if options.Nushell {
|
||||
printScript("key-bindings.nu", nushellKeyBindings)
|
||||
printScript("completion.nu", nushellCompletion)
|
||||
return
|
||||
}
|
||||
if options.Help {
|
||||
fmt.Print(fzf.Usage)
|
||||
return
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
..
|
||||
.TH fzf\-tmux 1 "Apr 2026" "fzf 0.71.0" "fzf\-tmux - open fzf in tmux split pane"
|
||||
.TH fzf\-tmux 1 "May 2026" "fzf 0.73.1" "fzf\-tmux - open fzf in tmux split pane"
|
||||
|
||||
.SH NAME
|
||||
fzf\-tmux - open fzf in tmux split pane
|
||||
|
||||
+69
-3
@@ -21,7 +21,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
..
|
||||
.TH fzf 1 "Apr 2026" "fzf 0.71.0" "fzf - a command-line fuzzy finder"
|
||||
.TH fzf 1 "May 2026" "fzf 0.73.1" "fzf - a command-line fuzzy finder"
|
||||
|
||||
.SH NAME
|
||||
fzf - a command-line fuzzy finder
|
||||
@@ -517,6 +517,8 @@ Draw border around the finder
|
||||
.br
|
||||
.BR double " Border with double lines"
|
||||
.br
|
||||
.BR dashed " Border with dashed lines and rounded corners"
|
||||
.br
|
||||
.BR block " Border using block elements; suitable when using different background colors"
|
||||
.br
|
||||
.BR thinblock " Border using legacy computing symbols; may not be displayed on some terminals"
|
||||
@@ -955,6 +957,8 @@ Should be used with one of the following \fB\-\-preview\-window\fR options.
|
||||
.br
|
||||
.B * border\-double
|
||||
.br
|
||||
.B * border\-dashed
|
||||
.br
|
||||
.B * border\-block
|
||||
.br
|
||||
.B * border\-thinblock
|
||||
@@ -989,9 +993,14 @@ border line.
|
||||
\fBdown
|
||||
\fBleft
|
||||
\fBright
|
||||
\fBnext
|
||||
|
||||
\fRDetermines the layout of the preview window.
|
||||
|
||||
* \fBnext\fR places the preview window adjacent to the input section, on
|
||||
the list side: above the input in the default layout, below the input
|
||||
in \fB\-\-layout=reverse\fR.
|
||||
|
||||
* If the argument contains \fB:hidden\fR, the preview window will be hidden by
|
||||
default until \fBtoggle\-preview\fR action is triggered.
|
||||
|
||||
@@ -1100,7 +1109,17 @@ Print header before the prompt line. When both normal header and header lines
|
||||
.TP
|
||||
.BI "\-\-header\-border" [=STYLE]
|
||||
Draw border around the header section. \fBline\fR style draws a single
|
||||
separator line between the header window and the list section.
|
||||
separator line between the header window and the list section. \fBinline\fR
|
||||
style embeds the header inside the list border frame, joined to the list
|
||||
section by a horizontal separator; it requires a \fB\-\-list\-border\fR
|
||||
shape that has both top and bottom segments (rounded / sharp / bold /
|
||||
double / dashed / block / thinblock / horizontal) and falls back to \fBline\fR
|
||||
otherwise. When the list border also has side segments, the separator
|
||||
joins them with T-junctions; \fBhorizontal\fR has no side borders, so the
|
||||
separator is drawn without T-junction endpoints. Takes precedence over
|
||||
\fB\-\-header\-first\fR (the section stays inside the list frame), and
|
||||
when \fB\-\-header\-lines\fR is also set \fB\-\-header\-lines\-border\fR
|
||||
must also be \fBinline\fR.
|
||||
|
||||
.TP
|
||||
.BI "\-\-header\-label" [=LABEL]
|
||||
@@ -1116,6 +1135,10 @@ Display header from \fB--header\-lines\fR with a separate border. Pass
|
||||
\fBnone\fR to still separate the header lines but without a border. To combine
|
||||
two headers, use \fB\-\-no\-header\-lines\-border\fR. \fBline\fR style draws
|
||||
a single separator line between the header lines and the list section.
|
||||
\fBinline\fR style embeds the header lines inside the list border frame
|
||||
with a horizontal separator; it requires a \fB\-\-list\-border\fR shape
|
||||
that has both top and bottom segments, falls back to \fBline\fR
|
||||
otherwise.
|
||||
|
||||
.SS FOOTER
|
||||
|
||||
@@ -1129,7 +1152,10 @@ are not affected by \fB\-\-with\-nth\fR. ANSI color codes are processed even whe
|
||||
.TP
|
||||
.BI "\-\-footer\-border" [=STYLE]
|
||||
Draw border around the footer section. \fBline\fR style draws a single
|
||||
separator line between the footer and the list section.
|
||||
separator line between the footer and the list section. \fBinline\fR style
|
||||
embeds the footer inside the list border frame with a horizontal separator;
|
||||
it requires a \fB\-\-list\-border\fR shape that has both top and bottom
|
||||
segments and falls back to \fBline\fR otherwise.
|
||||
|
||||
.TP
|
||||
.BI "\-\-footer\-label" [=LABEL]
|
||||
@@ -1347,6 +1373,12 @@ Print script to set up Fish shell integration
|
||||
|
||||
e.g. \fBfzf \-\-fish | source\fR
|
||||
|
||||
.TP
|
||||
.B "\-\-nushell"
|
||||
Print script to set up Nushell shell integration
|
||||
|
||||
e.g. \fBfzf \-\-nushell | save \-f ~/.config/nushell/autoload/_fzf_integration.nu\fR
|
||||
|
||||
.SS OTHERS
|
||||
.TP
|
||||
.B "\-\-no\-mouse"
|
||||
@@ -1449,6 +1481,8 @@ fzf exports the following environment variables to its child processes.
|
||||
.br
|
||||
.BR FZF_POS " Vertical position of the cursor in the list starting from 1"
|
||||
.br
|
||||
.BR FZF_CURRENT_ITEM " Text of the current item (unset if the list is empty)"
|
||||
.br
|
||||
.BR FZF_WRAP " The line wrapping mode (char, word) when enabled"
|
||||
.br
|
||||
.BR FZF_QUERY " Current query string"
|
||||
@@ -1479,6 +1513,10 @@ fzf exports the following environment variables to its child processes.
|
||||
.br
|
||||
.BR FZF_KEY " The name of the last key pressed"
|
||||
.br
|
||||
.BR FZF_IDLE_TIME " Whole seconds since the last user activity"
|
||||
.br
|
||||
.BR FZF_IDLE_TIME_MS " Milliseconds since the last user activity"
|
||||
.br
|
||||
.BR FZF_PORT " Port number when \-\-listen option is used"
|
||||
.br
|
||||
.BR FZF_SOCK " Unix socket path when \-\-listen option is used"
|
||||
@@ -1493,6 +1531,10 @@ fzf exports the following environment variables to its child processes.
|
||||
.br
|
||||
.BR FZF_RAW " Only in raw mode. 1 if the current item matches, 0 otherwise"
|
||||
|
||||
.PP
|
||||
.B FZF_CURRENT_ITEM
|
||||
is omitted when the item contains a NUL byte, because exec(2) cannot pass it.
|
||||
|
||||
.SH EXTENDED SEARCH MODE
|
||||
|
||||
Unless specified otherwise, fzf will start in "extended\-search mode". In this
|
||||
@@ -1918,6 +1960,30 @@ variables starting from 1. It optionally sets \fBFZF_CLICK_FOOTER_WORD\fR
|
||||
if clicked on a word.
|
||||
.RE
|
||||
|
||||
\fIevery(N)\fR
|
||||
.RS
|
||||
Triggered every \fIN\fR seconds (\fIN\fR can be a fractional number, e.g.
|
||||
\fB0.5\fR). The minimum interval is \fB0.01\fR seconds; values are floored
|
||||
to that.
|
||||
|
||||
Combine with the \fBFZF_IDLE_TIME\fR (whole seconds) and
|
||||
\fBFZF_IDLE_TIME_MS\fR (milliseconds) environment variables to build
|
||||
idle\-based behavior without a separate event.
|
||||
|
||||
e.g.
|
||||
\fB# Live process list, refreshed every 2 seconds.
|
||||
# --track --id-nth 2 keeps the cursor on the same PID across reloads.
|
||||
fzf \-\-header\-lines 1 \-\-track \-\-id\-nth 2 \\
|
||||
\-\-bind 'start,every(2):reload\-sync:ps \-ef'
|
||||
|
||||
# Auto\-accept after 10 seconds of inactivity, with a countdown in the footer after 5s.
|
||||
fzf \-\-bind 'every(1):bg\-transform:
|
||||
if [[ $FZF_IDLE_TIME \-lt 5 ]]; then echo change\-footer:
|
||||
elif [[ $FZF_IDLE_TIME \-lt 10 ]]; then echo "change\-footer:auto\-accept in $((10 \- FZF_IDLE_TIME))s"
|
||||
else echo accept
|
||||
fi'\fR
|
||||
.RE
|
||||
|
||||
.SS AVAILABLE ACTIONS:
|
||||
A key or an event can be bound to one or more of the following actions.
|
||||
|
||||
|
||||
+57
-25
@@ -896,6 +896,7 @@ function! s:execute_term(dict, command, temps) abort
|
||||
endif
|
||||
endfunction
|
||||
function! fzf.on_exit(id, code, ...)
|
||||
silent! autocmd! fzf_popup_resize
|
||||
if s:getpos() == self.ppos " {'window': 'enew'}
|
||||
for [opt, val] in items(self.winopts)
|
||||
execute 'let' opt '=' val
|
||||
@@ -1023,15 +1024,17 @@ function! s:callback(dict, lines) abort
|
||||
endfunction
|
||||
|
||||
if has('nvim')
|
||||
function s:create_popup(opts) abort
|
||||
function! s:create_popup() abort
|
||||
let opts = s:popup_bounds()
|
||||
let opts = extend({'relative': 'editor', 'style': 'minimal'}, opts)
|
||||
|
||||
let buf = nvim_create_buf(v:false, v:true)
|
||||
let opts = extend({'relative': 'editor', 'style': 'minimal'}, a:opts)
|
||||
let win = nvim_open_win(buf, v:true, opts)
|
||||
call setwinvar(win, '&colorcolumn', '')
|
||||
let s:popup_id = nvim_open_win(buf, v:true, opts)
|
||||
call setwinvar(s:popup_id, '&colorcolumn', '')
|
||||
|
||||
" Colors
|
||||
try
|
||||
call setwinvar(win, '&winhighlight', 'Pmenu:,Normal:Normal')
|
||||
call setwinvar(s:popup_id, '&winhighlight', 'Pmenu:,Normal:Normal')
|
||||
let rules = get(g:, 'fzf_colors', {})
|
||||
if has_key(rules, 'bg')
|
||||
let color = call('s:get_color', rules.bg)
|
||||
@@ -1039,40 +1042,61 @@ if has('nvim')
|
||||
let ns = nvim_create_namespace('fzf_popup')
|
||||
let hl = nvim_set_hl(ns, 'Normal',
|
||||
\ &termguicolors ? { 'bg': color } : { 'ctermbg': str2nr(color) })
|
||||
call nvim_win_set_hl_ns(win, ns)
|
||||
call nvim_win_set_hl_ns(s:popup_id, ns)
|
||||
endif
|
||||
endif
|
||||
catch
|
||||
endtry
|
||||
return buf
|
||||
endfunction
|
||||
|
||||
function! s:resize_popup() abort
|
||||
if !exists('s:popup_id') || !nvim_win_is_valid(s:popup_id)
|
||||
return
|
||||
endif
|
||||
let opts = s:popup_bounds()
|
||||
let opts = extend({'relative': 'editor'}, opts)
|
||||
call nvim_win_set_config(s:popup_id, opts)
|
||||
endfunction
|
||||
else
|
||||
function! s:create_popup(opts) abort
|
||||
let s:popup_create = {buf -> popup_create(buf, #{
|
||||
\ line: a:opts.row,
|
||||
\ col: a:opts.col,
|
||||
\ minwidth: a:opts.width,
|
||||
\ maxwidth: a:opts.width,
|
||||
\ minheight: a:opts.height,
|
||||
\ maxheight: a:opts.height,
|
||||
\ zindex: 1000,
|
||||
\ })}
|
||||
function! s:create_popup() abort
|
||||
function! s:popup_create(buf)
|
||||
let s:popup_id = popup_create(a:buf, #{zindex: 1000})
|
||||
call s:resize_popup()
|
||||
endfunction
|
||||
autocmd TerminalOpen * ++once call s:popup_create(str2nr(expand('<abuf>')))
|
||||
endfunction
|
||||
|
||||
function! s:resize_popup() abort
|
||||
if !exists('s:popup_id') || empty(popup_getpos(s:popup_id))
|
||||
return
|
||||
endif
|
||||
let opts = s:popup_bounds()
|
||||
call popup_move(s:popup_id, {
|
||||
\ 'line': opts.row,
|
||||
\ 'col': opts.col,
|
||||
\ 'minwidth': opts.width,
|
||||
\ 'maxwidth': opts.width,
|
||||
\ 'minheight': opts.height,
|
||||
\ 'maxheight': opts.height,
|
||||
\ })
|
||||
endfunction
|
||||
endif
|
||||
|
||||
function! s:popup(opts) abort
|
||||
let xoffset = get(a:opts, 'xoffset', 0.5)
|
||||
let yoffset = get(a:opts, 'yoffset', 0.5)
|
||||
let relative = get(a:opts, 'relative', 0)
|
||||
function! s:popup_bounds() abort
|
||||
let opts = s:popup_opts
|
||||
|
||||
let xoffset = get(opts, 'xoffset', 0.5)
|
||||
let yoffset = get(opts, 'yoffset', 0.5)
|
||||
let relative = get(opts, 'relative', 0)
|
||||
|
||||
" Use current window size for positioning relatively positioned popups
|
||||
let columns = relative ? winwidth(0) : &columns
|
||||
let lines = relative ? winheight(0) : (&lines - has('nvim'))
|
||||
|
||||
" Size and position
|
||||
let width = min([max([8, a:opts.width > 1 ? a:opts.width : float2nr(columns * a:opts.width)]), columns])
|
||||
let height = min([max([4, a:opts.height > 1 ? a:opts.height : float2nr(lines * a:opts.height)]), lines])
|
||||
let width = min([max([8, opts.width > 1 ? opts.width : float2nr(columns * opts.width)]), columns])
|
||||
let height = min([max([4, opts.height > 1 ? opts.height : float2nr(lines * opts.height)]), lines])
|
||||
let row = float2nr(yoffset * (lines - height)) + (relative ? win_screenpos(0)[0] - 1 : 0)
|
||||
let col = float2nr(xoffset * (columns - width)) + (relative ? win_screenpos(0)[1] - 1 : 0)
|
||||
|
||||
@@ -1082,9 +1106,17 @@ function! s:popup(opts) abort
|
||||
let row += !has('nvim')
|
||||
let col += !has('nvim')
|
||||
|
||||
call s:create_popup({
|
||||
\ 'row': row, 'col': col, 'width': width, 'height': height
|
||||
\ })
|
||||
return { 'row': row, 'col': col, 'width': width, 'height': height }
|
||||
endfunction
|
||||
|
||||
function! s:popup(opts) abort
|
||||
let s:popup_opts = a:opts
|
||||
call s:create_popup()
|
||||
|
||||
augroup fzf_popup_resize
|
||||
autocmd!
|
||||
autocmd VimResized * call s:resize_popup()
|
||||
augroup END
|
||||
endfunction
|
||||
|
||||
let s:default_action = {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# ____ ____
|
||||
# / __/___ / __/
|
||||
# / /_/_ / / /_
|
||||
# / __/ / /_/ __/
|
||||
# /_/ /___/_/ completion-examples.nu
|
||||
#
|
||||
# Example custom completers for fzf's Nushell integration.
|
||||
#
|
||||
# To use these, add the desired entries to $env.FZF_COMPLETERS in your
|
||||
# config.nu. Each closure receives two arguments:
|
||||
# - prefix: the text before the trigger (e.g. "vim" in "vim **<TAB>")
|
||||
# - spans: the full command as a list of words (e.g. ["pacman", "-S", "vim**"])
|
||||
#
|
||||
# A closure can return either:
|
||||
# - a list of candidate strings (fzf will use default options), or
|
||||
# - a record with the following optional fields:
|
||||
# candidates: list<string> # candidates to feed to fzf
|
||||
# opts: list<string> # custom fzf options (default: ["-m"])
|
||||
# post: closure (|sel| ...) # post-processing of the selected item
|
||||
#
|
||||
# Simple example:
|
||||
# $env.FZF_COMPLETERS = {
|
||||
# git: {|prefix, spans| ["branch-main", "branch-dev", "branch-feature"]}
|
||||
# }
|
||||
|
||||
# --- pacman / paru ---
|
||||
# Completes package names for pacman and paru.
|
||||
# Uses the spans to distinguish between subcommands:
|
||||
# -S (sync), -F (files): list available packages from repos
|
||||
# -Q (query), -R (remove): list installed packages
|
||||
# Returns a record with custom fzf options for package preview.
|
||||
|
||||
$env.FZF_COMPLETERS = {}
|
||||
|
||||
$env.FZF_COMPLETERS.pacman = {|prefix, spans|
|
||||
let sub = $spans | skip 1 | first
|
||||
let candidates = (if ($sub =~ "-[SF]") {
|
||||
^pacman -Slq | lines
|
||||
} else if ($sub =~ "-[QR]") {
|
||||
^pacman -Qq | lines
|
||||
} else {
|
||||
[]
|
||||
})
|
||||
{
|
||||
candidates: $candidates
|
||||
opts: ["-m", "--preview", "pacman -Si {}", "--prompt", "Package > "]
|
||||
}
|
||||
}
|
||||
|
||||
$env.FZF_COMPLETERS.paru = {|prefix, spans|
|
||||
let sub = $spans | skip 1 | first
|
||||
let candidates = (if ($sub =~ "-[SF]") {
|
||||
^pacman -Slq | lines
|
||||
} else if ($sub =~ "-[QR]") {
|
||||
^pacman -Qq | lines
|
||||
} else {
|
||||
[]
|
||||
})
|
||||
{
|
||||
candidates: $candidates
|
||||
opts: ["-m", "--preview", "pacman -Si {}", "--prompt", "Package > "]
|
||||
}
|
||||
}
|
||||
|
||||
# --- pass (password-store) ---
|
||||
# Completes entry names from ~/.password-store.
|
||||
# Returns a simple list (no custom fzf options needed).
|
||||
|
||||
$env.FZF_COMPLETERS.pass = {|prefix, spans|
|
||||
try {
|
||||
ls ~/.password-store/**/*.gpg
|
||||
| get name
|
||||
| each {$in | str replace -r '^.*?\.password-store/(.*).gpg' '${1}'}
|
||||
} catch {
|
||||
[]
|
||||
}
|
||||
}
|
||||
|
||||
# --- Example with post-processing hook ---
|
||||
# The "post" closure transforms the selected line after fzf returns.
|
||||
# This is useful when the displayed line contains more information than
|
||||
# what you want inserted on the command line (e.g. extracting a PID from
|
||||
# a full "ps" output line).
|
||||
#
|
||||
# $env.FZF_COMPLETERS.mycommand = {|prefix, spans|
|
||||
# {
|
||||
# candidates: (^some-command | lines)
|
||||
# opts: ["+m", "--header-lines=1"]
|
||||
# post: {|selection| $selection | split row ' ' | get 0}
|
||||
# }
|
||||
# }
|
||||
@@ -0,0 +1,489 @@
|
||||
# ____ ____
|
||||
# / __/___ / __/
|
||||
# / /_/_ / / /_
|
||||
# / __/ / /_/ __/
|
||||
# /_/ /___/_/ completion.nu
|
||||
|
||||
|
||||
# An implementation of completion.nu
|
||||
# This loads FZF as a Nushell External Completer
|
||||
# https://www.nushell.sh/cookbook/external_completers.html
|
||||
|
||||
|
||||
# --- Default Environment Variables ---
|
||||
# These can be overridden in your config.nu or environment.
|
||||
# Example: $env.FZF_COMPLETION_TRIGGER = "!<TAB>"
|
||||
|
||||
# - $env.FZF_TMUX (default: 0)
|
||||
# - $env.FZF_TMUX_OPTS (default: empty)
|
||||
# - $env.FZF_TMUX_HEIGHT (default: 40%)
|
||||
# - $env.FZF_COMPLETION_TRIGGER (default: '**')
|
||||
# - $env.FZF_COMPLETION_OPTS (default: empty)
|
||||
# - $env.FZF_COMPLETION_PATH_OPTS (default: empty)
|
||||
# - $env.FZF_COMPLETION_DIR_OPTS (default: empty)
|
||||
|
||||
|
||||
|
||||
$env.FZF_COMPLETION_TRIGGER = $env.FZF_COMPLETION_TRIGGER? | default '**'
|
||||
|
||||
# Options for fzf completion in general. e.g. '--border'
|
||||
$env.FZF_COMPLETION_OPTS = $env.FZF_COMPLETION_OPTS? | default ''
|
||||
|
||||
# Options specific to path completion. e.g. '--extended'
|
||||
$env.FZF_COMPLETION_PATH_OPTS = $env.FZF_COMPLETION_PATH_OPTS? | default ''
|
||||
# Options specific to directory completion. e.g. '--extended'
|
||||
$env.FZF_COMPLETION_DIR_OPTS = $env.FZF_COMPLETION_DIR_OPTS? | default ''
|
||||
|
||||
$env.FZF_COMPLETION_DIR_COMMANDS = $env.FZF_COMPLETION_DIR_COMMANDS? | default ['cd', 'pushd', 'rmdir']
|
||||
|
||||
# --- Helper Functions ---
|
||||
|
||||
# Helper to build default fzf options list
|
||||
def __fzf_defaults_completion [prepend: string, append: string]: nothing -> string {
|
||||
let base = $"--height ($env.FZF_TMUX_HEIGHT? | default '40%') --min-height 20+ --bind=ctrl-z:ignore ($prepend)"
|
||||
let opts_file = if ($env.FZF_DEFAULT_OPTS_FILE? | default '' | is-not-empty) {
|
||||
try { open --raw ($env.FZF_DEFAULT_OPTS_FILE) | str trim } catch { '' }
|
||||
} else {
|
||||
''
|
||||
}
|
||||
let default_opts = $env.FZF_DEFAULT_OPTS? | default ''
|
||||
$"($base) ($opts_file) ($default_opts) ($append)" | str trim
|
||||
}
|
||||
|
||||
# Wrapper for running fzf or fzf-tmux
|
||||
def __fzf_comprun [ context_name: string # e.g., "fzf-completion" , "fzf-helper" - mainly for potential debugging
|
||||
, query: string # The initial query string for fzf
|
||||
, fzf_opts_arg: list<string> # Remaining options for fzf/fzf-tmux
|
||||
] {
|
||||
let stdin_content = try {
|
||||
# Collect stdin into a single string. Adjust if structured data is expected.
|
||||
$in | into string
|
||||
} catch {
|
||||
null # Set to null if there's no stdin or an error occurs reading it
|
||||
}
|
||||
|
||||
let fzf_default_opts = (__fzf_defaults_completion "" ($env.FZF_COMPLETION_OPTS | default ''))
|
||||
let fzf_prefinal_opt = ['--query', $query, '--reverse'] | append $fzf_opts_arg
|
||||
|
||||
# Get the configured height, defaulting to '40%'
|
||||
let height_opt = $env.FZF_TMUX_HEIGHT? | default '40%'
|
||||
|
||||
# Determine if fzf should generate its own candidates via walker
|
||||
let has_walker = ($fzf_prefinal_opt | find '--walker' | is-not-empty)
|
||||
|
||||
# Check for custom comprun function (Nu equivalent)
|
||||
if (which _fzf_comprun | is-not-empty) {
|
||||
# Note: Nushell doesn't have a direct equivalent to Zsh/Bash `type -t _fzf_comprun`.
|
||||
# This check assumes a user might define a custom command named `_fzf_comprun`.
|
||||
_fzf_comprun $context_name $query ...$fzf_prefinal_opt # Pass args correctly to custom function
|
||||
} else if ($env.TMUX_PANE? | default '' | into string | is-not-empty) and (($env.FZF_TMUX? | default 0) != 0 or ($env.FZF_TMUX_OPTS? | is-not-empty)) {
|
||||
# Running inside tmux, use fzf-tmux
|
||||
let final_fzf_opts = if ($env.FZF_TMUX_OPTS? | is-not-empty) {
|
||||
$env.FZF_TMUX_OPTS | split row ' ' | append ['--'] | append $fzf_prefinal_opt
|
||||
} else {
|
||||
# Use the default -d option with the configured height for fzf-tmux
|
||||
['-d', $height_opt, '--'] | append $fzf_prefinal_opt
|
||||
}
|
||||
|
||||
if $has_walker or ($stdin_content == null) {
|
||||
with-env { FZF_DEFAULT_OPTS: $fzf_default_opts, FZF_DEFAULT_OPTS_FILE: '' } { fzf-tmux ...$final_fzf_opts }
|
||||
} else {
|
||||
$stdin_content | with-env { FZF_DEFAULT_OPTS: $fzf_default_opts, FZF_DEFAULT_OPTS_FILE: '' } { fzf-tmux ...$final_fzf_opts }
|
||||
}
|
||||
|
||||
} else {
|
||||
# Not in tmux or not configured for fzf-tmux, use fzf directly
|
||||
let final_fzf_opts = $fzf_prefinal_opt
|
||||
|
||||
if $has_walker or ($stdin_content == null) {
|
||||
with-env { FZF_DEFAULT_OPTS: $fzf_default_opts, FZF_DEFAULT_OPTS_FILE: '' } { fzf ...$final_fzf_opts }
|
||||
} else {
|
||||
$stdin_content | with-env { FZF_DEFAULT_OPTS: $fzf_default_opts, FZF_DEFAULT_OPTS_FILE: '' } { fzf ...$final_fzf_opts }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Generate host list for ssh/telnet
|
||||
def __fzf_list_hosts [] {
|
||||
# Translate the Zsh pipeline using Nu commands and external tools
|
||||
let ssh_configs = try { open ~/.ssh/config | lines } catch { [] }
|
||||
let ssh_configs_d = try { open ~/.ssh/config.d/* | lines } catch { [] }
|
||||
let ssh_config_global = try { open /etc/ssh/ssh_config | lines } catch { [] }
|
||||
let known_hosts = try { open ~/.ssh/known_hosts | lines } catch { [] }
|
||||
let hosts_file = try { open /etc/hosts | lines } catch { [] }
|
||||
|
||||
[
|
||||
(
|
||||
# Process ssh config files
|
||||
$ssh_configs | append $ssh_configs_d | append $ssh_config_global
|
||||
| where {|it| ($it | str downcase | str starts-with 'host') or ($it | str downcase | str starts-with 'hostname') }
|
||||
| parse --regex '^\s*host(?:name)?\s+(?<hosts>.+)' # Extract hosts after keyword
|
||||
| default { hosts: null } # Handle lines that don't match regex
|
||||
| get hosts
|
||||
| where {|it| $it != null }
|
||||
| split row ' '
|
||||
| where {|it| not ($it =~ '[*?%]') } # Exclude patterns containing *, ?, or %
|
||||
)
|
||||
(
|
||||
# Process known_hosts file
|
||||
$known_hosts | parse --regex '^(?:\[)?(?<hosts>[a-z0-9.,:_-]+)' # Extract hostnames (possibly in [], possibly comma-separated) - added underscore
|
||||
| default { hosts: null }
|
||||
| get hosts
|
||||
| where {|it| $it != null }
|
||||
| each { |it| $it | split row ',' } # Split comma-separated hosts if any
|
||||
| flatten
|
||||
)
|
||||
(
|
||||
# Process /etc/hosts file
|
||||
$hosts_file | where { |it| not ($it | str starts-with '#') } # Ignore comments
|
||||
| where { |it| not ($it | str trim | is-empty) } # Ignore empty lines
|
||||
| where { |it| not ($it | str contains '0.0.0.0') } # Ignore 0.0.0.0
|
||||
| str replace --regex '#.*$' '' # Remove trailing comments
|
||||
| parse --regex '^\s*\S+\s+(?<hosts>.+)' # Extract hosts part (after IP)
|
||||
| default { hosts: null }
|
||||
| get hosts
|
||||
| where {|it| $it != null }
|
||||
| split row ' ' # Split multiple hosts on the same line
|
||||
)
|
||||
]
|
||||
| flatten # Combine all lists into a single stream
|
||||
| where {|it| not ($it | is-empty) } # Remove empty entries
|
||||
| sort | uniq # Sort and remove duplicates
|
||||
}
|
||||
|
||||
|
||||
# Base function for path/directory completion
|
||||
def __fzf_generic_path_completion [ prefix: string # The text before the trigger
|
||||
, fzf_opts_arg: list<string> # Extra options for fzf
|
||||
, suffix: string # Suffix to add to selection (e.g. , "/")
|
||||
] {
|
||||
# --- Determine walker root and initial query from the prefix ---
|
||||
|
||||
mut walker_root = "."
|
||||
mut initial_query = ""
|
||||
|
||||
if ($prefix | is-empty) {
|
||||
# Case: "**"
|
||||
$walker_root = "."
|
||||
$initial_query = ""
|
||||
} else if ($prefix | str contains (char separator)) {
|
||||
# Case: "dir/subdir/partial**" or "dir/**"
|
||||
$walker_root = $prefix | path dirname
|
||||
$initial_query = $prefix | path basename
|
||||
# Handle edge case where prefix ends with separator, e.g., "dir/"
|
||||
if ($prefix | str ends-with (char separator)) {
|
||||
# Remove trailing separator to get the intended directory
|
||||
$walker_root = $prefix | str substring 0..-2
|
||||
$initial_query = ""
|
||||
}
|
||||
# Ensure walker_root isn't empty if prefix was like "/file**"
|
||||
# or if path dirname returned empty string for some reason (e.g. prefix="file/")
|
||||
if ($walker_root | is-empty) {
|
||||
if ($prefix | str starts-with (char separator)) {
|
||||
$walker_root = (char separator)
|
||||
} else if ($prefix | str ends-with (char separator)) {
|
||||
$walker_root = $prefix | str substring 0..-2
|
||||
} else { $walker_root = "." } # Fallback if dirname weirdly fails
|
||||
}
|
||||
} else {
|
||||
# Case: "partial**" (no slashes)
|
||||
$walker_root = "."
|
||||
$initial_query = $prefix
|
||||
}
|
||||
|
||||
# --- Prepare FZF options ---
|
||||
let completion_type_opts = if $suffix == '/' {
|
||||
$env.FZF_COMPLETION_DIR_OPTS? | default '' | split row ' ' | where {not ($in | is-empty)}
|
||||
} else {
|
||||
$env.FZF_COMPLETION_PATH_OPTS? | default '' | split row ' ' | where {not ($in | is-empty)}
|
||||
}
|
||||
|
||||
let walker_type = if ($suffix == '/') {
|
||||
"dir,follow"
|
||||
} else {
|
||||
"file,dir,follow,hidden"
|
||||
}
|
||||
# Expand tilde so fzf receives a valid absolute path as walker-root
|
||||
let needs_tilde_rewrite = ($walker_root | str starts-with '~')
|
||||
let walker_root_expanded = ($walker_root | path expand)
|
||||
|
||||
# Use the 'walker_root' calculated at the beginning
|
||||
let fzf_all_opts = ["--scheme=path", "--walker", $walker_type, "--walker-root", $walker_root_expanded] | append $fzf_opts_arg
|
||||
| append $completion_type_opts
|
||||
|
||||
# Call FZF run
|
||||
let fzf_selection = ( __fzf_comprun "fzf-path-completion-walker" $initial_query $fzf_all_opts ) | str trim
|
||||
|
||||
|
||||
# --- Return Result ---
|
||||
if ($fzf_selection | is-not-empty) {
|
||||
# Restore tilde prefix if the user originally typed ~/
|
||||
let home = $nu.home-dir | path expand
|
||||
let result = if $needs_tilde_rewrite {
|
||||
$fzf_selection | lines | each {|line| $line | str replace $home '~' } | str join ' '
|
||||
} else {
|
||||
$fzf_selection | lines | str join ' '
|
||||
}
|
||||
[$result]
|
||||
} else {
|
||||
[]
|
||||
}
|
||||
}
|
||||
|
||||
# Specific path completion wrapper
|
||||
def _fzf_path_completion [prefix: string] {
|
||||
# Zsh args: base, lbuf, _fzf_compgen_path, "-m", "", " "
|
||||
# Nu: prefix, empty command name (use find), ["-m"], "", " "
|
||||
__fzf_generic_path_completion $prefix ["-m"] ""
|
||||
}
|
||||
|
||||
# General completion helper for commands that feed a list to fzf
|
||||
# This is called by ssh, kill, and user-defined completers.
|
||||
def _fzf_complete [ query: string # The initial query string for fzf
|
||||
, data_gen_closure: closure # Closure that generates candidates
|
||||
, fzf_opts_arg: list<string> # Extra options for fzf (like -m, +m)
|
||||
, --post_process_closure: closure # Closure to process the selected item (optional)
|
||||
] {
|
||||
# Generate candidates using the provided command
|
||||
let candidates = try {
|
||||
do $data_gen_closure
|
||||
} catch {
|
||||
# Capture the actual error object provided by the catch block
|
||||
let actual_error = $in
|
||||
# Print a more informative error message including the actual error details
|
||||
print -e $"Error executing data_gen closure. Closure code: ($data_gen_closure). Actual error: ($actual_error)"
|
||||
[]
|
||||
}
|
||||
|
||||
# Run fzf and get selection
|
||||
let fzf_selection = $candidates | to text
|
||||
| __fzf_comprun "fzf-helper" $query $fzf_opts_arg
|
||||
| str trim # Trim potential trailing newline from fzf
|
||||
|
||||
# Apply post-processing if closure provided and selection is not empty
|
||||
let processed_selection = if ($fzf_selection | is-not-empty) and ($post_process_closure | is-not-empty) {
|
||||
# Call the post-processing closure with the selection
|
||||
try {
|
||||
do $post_process_closure $fzf_selection
|
||||
} catch {
|
||||
print -e $"Error executing post_process closure: ($post_process_closure)"
|
||||
$fzf_selection # Return original selection on error
|
||||
}
|
||||
} else {
|
||||
$fzf_selection
|
||||
}
|
||||
|
||||
if not ($processed_selection | is-empty) {
|
||||
[($processed_selection | lines | str join ' ')]
|
||||
} else {
|
||||
[]
|
||||
}
|
||||
}
|
||||
|
||||
# SSH/Telnet completion
|
||||
def _fzf_complete_ssh [ prefix: string
|
||||
, input_line_before_trigger: string
|
||||
] {
|
||||
let words = ($input_line_before_trigger | split row ' ')
|
||||
let word_count = $words | length
|
||||
|
||||
# Find the index of the word being completed (which is the prefix)
|
||||
# If prefix is empty, completion happens after a space, index is word_count
|
||||
# If prefix is not empty, it's the last word, index is word_count - 1
|
||||
let completion_index = if ($prefix | is-empty) { $word_count } else { $word_count - 1 }
|
||||
|
||||
mut handled = false
|
||||
mut completion_result = [] # List of completion strings to return
|
||||
|
||||
# Check for -i, -F, -E flags immediately preceding the cursor position
|
||||
if $completion_index > 0 {
|
||||
let prev_arg = ($words | get ($completion_index - 1))
|
||||
if ($prev_arg in ['-i', '-F', '-E']) {
|
||||
$handled = true
|
||||
# Call path completion with the current prefix
|
||||
$completion_result = (_fzf_path_completion $prefix)
|
||||
}
|
||||
}
|
||||
|
||||
# If not handled by path completion, do host completion
|
||||
if not $handled {
|
||||
let user_part = if ($prefix | str contains "@") { ($prefix | split row "@" | first) + "@" } else { "" }
|
||||
# The part after '@' (or the whole prefix if no '@') is the initial query for fzf
|
||||
let query = if ($prefix | str contains "@") { $prefix | split row "@" | last } else { $prefix }
|
||||
|
||||
let host_candidates_gen = {||
|
||||
__fzf_list_hosts
|
||||
| each {|host_item| $user_part + $host_item } # Prepend user@ if present in prefix
|
||||
}
|
||||
|
||||
# Zsh options: +m -- ; Nu: pass ["+m"]
|
||||
# Pass the host part of the prefix to _fzf_complete for the initial query
|
||||
let selected_host = (_fzf_complete $query $host_candidates_gen ["+m"]) # Pass host_prefix here
|
||||
if not ($selected_host | is-empty) {
|
||||
$completion_result = $selected_host # _fzf_complete returns a list
|
||||
}
|
||||
}
|
||||
|
||||
$completion_result
|
||||
}
|
||||
|
||||
# Kill completion post-processor (extracts PID)
|
||||
def _fzf_complete_kill_post_get_pid [selected_line: string] {
|
||||
# Assuming standard ps output where PID is the second column
|
||||
$selected_line | lines | each { $in | from ssv --noheaders | get 0.column1 } | to text
|
||||
}
|
||||
|
||||
# Kill completion to get process PID
|
||||
def _fzf_complete_kill [query: string] {
|
||||
let ps_gen_closure = {|| # Define ps generator as a closure
|
||||
# Try standard ps, then busybox, then cygwin format approximation
|
||||
# Use `^ps` to ensure external command execution
|
||||
try {
|
||||
^ps -eo user,pid,ppid,start,time,command | complete | if $in.exit_code == 0 { $in.stdout | lines } else { error make {msg: "ps failed"} }
|
||||
} catch {
|
||||
try {
|
||||
^ps -eo user,pid,ppid,time,args | complete | if $in.exit_code == 0 { $in.stdout | lines } else { error make {msg: "ps failed"} }
|
||||
} catch {
|
||||
try {
|
||||
^ps --everyone --full --windows | complete | if $in.exit_code == 0 { $in.stdout | lines } else { error make {msg: "ps failed"} }
|
||||
} catch {
|
||||
print -e "Error: ps command failed."
|
||||
[] # Return empty list on failure
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Note: Complex Zsh FZF bindings for kill (click-header transformer) are omitted for simplicity.
|
||||
# Users can set custom bindings via FZF_DEFAULT_OPTS if needed.
|
||||
let kill_post_closure = {|selected_line| _fzf_complete_kill_post_get_pid $selected_line }
|
||||
|
||||
let fzf_opts = ["-m", "--header-lines=1", "--no-preview", "--wrap", "--color", "fg:dim,nth:regular"]
|
||||
|
||||
_fzf_complete $query $ps_gen_closure $fzf_opts --post_process_closure $kill_post_closure
|
||||
}
|
||||
|
||||
|
||||
# --- Main FZF External Completer ---
|
||||
|
||||
# This function is registered with Nushell's external completion system.
|
||||
# It gets called when Tab is pressed.
|
||||
let fzf_external_completer = {|spans|
|
||||
let trigger: string = $env.FZF_COMPLETION_TRIGGER? | default '**'
|
||||
|
||||
if ($trigger | is-empty) { return null } # Cannot work with empty trigger
|
||||
if (($spans | length ) == 0) { return null } # Nothing to complete
|
||||
|
||||
let last_span = $spans | last
|
||||
|
||||
if ($last_span | str ends-with $trigger) {
|
||||
# --- Trigger Found ---
|
||||
|
||||
# Skip sudo to determine the actual command
|
||||
let cmd_spans = if ($spans | first) == "sudo" { $spans | skip 1 } else { $spans }
|
||||
let cmd_word = ($cmd_spans | first | default "")
|
||||
|
||||
# Calculate the prefix (part before the trigger in the last span)
|
||||
let prefix = $last_span | str substring 0..(-1 * ($trigger | str length) - 1)
|
||||
|
||||
# Reconstruct the line content *before* the trigger for context
|
||||
# This is an approximation based on spans
|
||||
let line_without_trigger = $cmd_spans | take (($cmd_spans | length) - 1) | append $prefix | str join ' '
|
||||
|
||||
# --- Dispatch to Completer ---
|
||||
mut completion_results = [] # Will hold the list of strings from the completer
|
||||
|
||||
# Check for user-defined completer in $env.FZF_COMPLETERS first.
|
||||
# Users can define custom completers in their config.nu as a record of closures:
|
||||
# $env.FZF_COMPLETERS = { git: {|prefix, spans| ... }, docker: {|prefix, spans| ... } }
|
||||
# Each closure receives the prefix (text before the trigger) and the full
|
||||
# command spans (e.g. ["pacman", "-S", "vim**"]), and should return either:
|
||||
# - a list of candidate strings, or
|
||||
# - a record { candidates: [...], opts: [...], post: {|sel| ...} } to pass
|
||||
# custom fzf options and/or a post-processing closure.
|
||||
# See shell/completion-examples.nu for examples.
|
||||
let user_completers = ($env.FZF_COMPLETERS? | default {})
|
||||
if ($cmd_word in $user_completers) {
|
||||
let user_gen = ($user_completers | get $cmd_word)
|
||||
let user_result = (do $user_gen $prefix $cmd_spans)
|
||||
if ($user_result | describe | str starts-with 'record') {
|
||||
let candidates = ($user_result | get candidates)
|
||||
let fzf_opts = ($user_result | get opts? | default ["-m"])
|
||||
let post = ($user_result | get post? | default null)
|
||||
if ($post != null) {
|
||||
$completion_results = (_fzf_complete $prefix {|| $candidates} $fzf_opts --post_process_closure $post)
|
||||
} else {
|
||||
$completion_results = (_fzf_complete $prefix {|| $candidates} $fzf_opts)
|
||||
}
|
||||
} else {
|
||||
$completion_results = (_fzf_complete $prefix {|| $user_result} ["-m"])
|
||||
}
|
||||
} else {
|
||||
match $cmd_word {
|
||||
"ssh" | "scp" | "sftp" | "telnet" => { $completion_results = (_fzf_complete_ssh $prefix $line_without_trigger) }
|
||||
"kill" => { $completion_results = (_fzf_complete_kill $prefix) }
|
||||
_ if ($cmd_word in $env.FZF_COMPLETION_DIR_COMMANDS) => {
|
||||
$completion_results = (__fzf_generic_path_completion $prefix [] "/")
|
||||
}
|
||||
_ => {
|
||||
# Default to path completion if no specific command matches
|
||||
$completion_results = (_fzf_path_completion $prefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- Return Results ---
|
||||
# The _fzf_... functions return a list of completion strings.
|
||||
# Nushell's completer expects the suggestions for the token being completed (prefix + trigger).
|
||||
# The results from the helper functions should be the final desired strings.
|
||||
# We don't need to manually add spaces; Nushell handles that.
|
||||
$completion_results # Return the list directly
|
||||
} else {
|
||||
# --- Trigger Not Found ---
|
||||
# Return null to let Nushell fall back to other completers (e.g., default file completion).
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
# --- WRAPPER AND REGISTRATION ---
|
||||
|
||||
# Guard against re-sourcing: wrapping the completer multiple times would
|
||||
# nest wrappers and grow the call chain on every reload.
|
||||
if ($env.__fzf_completer_registered? | default false) != true {
|
||||
|
||||
# Get the currently configured external completer, if any exists
|
||||
let previous_external_completer = $env.config? | get completions? | get external? | get completer?
|
||||
|
||||
# Define the new wrapper completer
|
||||
let fzf_wrapper_completer = {|spans|
|
||||
# 1. Try the FZF completer logic first
|
||||
let fzf_result = do $fzf_external_completer $spans
|
||||
|
||||
# 2. If FZF returned a result (a list, even an empty one), return it.
|
||||
# `null` means FZF didn't handle it because the trigger wasn't present.
|
||||
if $fzf_result != null {
|
||||
$fzf_result
|
||||
} else {
|
||||
# 3. FZF didn't handle it, so call the previous completer (if it exists).
|
||||
if $previous_external_completer != null {
|
||||
do $previous_external_completer $spans
|
||||
} else {
|
||||
# 4. No previous completer, and FZF didn't handle it. Return null.
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Register the new wrapper completer
|
||||
# This ensures external completions are enabled and sets our wrapper.
|
||||
$env.config = $env.config | upsert completions {
|
||||
external: {
|
||||
enable: true
|
||||
completer: $fzf_wrapper_completer
|
||||
}
|
||||
}
|
||||
|
||||
$env.__fzf_completer_registered = true
|
||||
}
|
||||
|
||||
# vim: set sts=2 ts=2 sw=2 tw=120 et :
|
||||
@@ -84,6 +84,10 @@ __fzf_history_delete() {
|
||||
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
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
# ____ ____
|
||||
# / __/___ / __/
|
||||
# / /_/_ / / /_
|
||||
# / __/ / /_/ __/
|
||||
# /_/ /___/_/ key-bindings.nu
|
||||
#
|
||||
# - $FZF_TMUX (default: 0)
|
||||
# - $FZF_TMUX_OPTS
|
||||
# - $FZF_TMUX_HEIGHT (default: 40%)
|
||||
# - $FZF_CTRL_T_COMMAND (set to "" to disable)
|
||||
# - $FZF_CTRL_T_OPTS
|
||||
# - $FZF_CTRL_R_COMMAND (set to "" to disable)
|
||||
# - $FZF_CTRL_R_OPTS
|
||||
# - $FZF_ALT_C_COMMAND (set to "" to disable)
|
||||
# - $FZF_ALT_C_OPTS
|
||||
|
||||
# Code provided by @igor-ramazanov
|
||||
# Source: https://github.com/junegunn/fzf/issues/4122#issuecomment-2607368316
|
||||
|
||||
# Merge default options in the same order as bash/zsh:
|
||||
# 1. --height, --min-height, --bind=ctrl-z:ignore, $prepend
|
||||
# 2. $FZF_DEFAULT_OPTS_FILE contents
|
||||
# 3. $FZF_DEFAULT_OPTS, $append
|
||||
def __fzf_defaults [prepend: string, append: string]: nothing -> string {
|
||||
let base = $"--height ($env.FZF_TMUX_HEIGHT? | default '40%') --min-height 20+ --bind=ctrl-z:ignore ($prepend)"
|
||||
let opts_file = if ($env.FZF_DEFAULT_OPTS_FILE? | default '' | is-not-empty) {
|
||||
try { open --raw ($env.FZF_DEFAULT_OPTS_FILE) | str trim } catch { '' }
|
||||
} else {
|
||||
''
|
||||
}
|
||||
let default_opts = $env.FZF_DEFAULT_OPTS? | default ''
|
||||
$"($base) ($opts_file) ($default_opts) ($append)" | str trim
|
||||
}
|
||||
|
||||
# Return the fzf command to use: fzf-tmux when inside tmux and
|
||||
# FZF_TMUX is enabled or FZF_TMUX_OPTS is set, plain fzf otherwise.
|
||||
def __fzfcmd []: nothing -> list<string> {
|
||||
let in_tmux = ($env.TMUX_PANE? | default '' | into string | is-not-empty)
|
||||
if $in_tmux {
|
||||
let fzf_tmux = ($env.FZF_TMUX? | default 0 | into string)
|
||||
let fzf_tmux_opts = ($env.FZF_TMUX_OPTS? | default '' | into string)
|
||||
if ($fzf_tmux != '0') or ($fzf_tmux_opts | is-not-empty) {
|
||||
let opts = if ($fzf_tmux_opts | is-not-empty) { $fzf_tmux_opts } else { $"-d($env.FZF_TMUX_HEIGHT? | default '40%')" }
|
||||
return ['fzf-tmux' ...(($opts | split row ' ' | where { $in != '' })) '--']
|
||||
}
|
||||
}
|
||||
['fzf']
|
||||
}
|
||||
|
||||
|
||||
export-env {
|
||||
$env.FZF_CTRL_T_OPTS = $env.FZF_CTRL_T_OPTS? | default ""
|
||||
$env.FZF_CTRL_R_OPTS = $env.FZF_CTRL_R_OPTS? | default ""
|
||||
$env.FZF_ALT_C_OPTS = $env.FZF_ALT_C_OPTS? | default ""
|
||||
}
|
||||
|
||||
# Directories
|
||||
const alt_c = {
|
||||
name: fzf_dirs
|
||||
modifier: alt
|
||||
keycode: char_c
|
||||
mode: [emacs, vi_normal, vi_insert]
|
||||
event: [
|
||||
{
|
||||
send: executehostcommand
|
||||
cmd: "
|
||||
let fzf_opts = (__fzf_defaults '--reverse --walker=dir,follow,hidden --scheme=path' $'($env.FZF_ALT_C_OPTS) +m');
|
||||
let fzfcmd = (__fzfcmd);
|
||||
let fzf_args = ($fzfcmd | skip 1);
|
||||
let alt_c_cmd = ($env.FZF_ALT_C_COMMAND? | default null);
|
||||
let result = if ($alt_c_cmd == null) or ($alt_c_cmd | is-empty) {
|
||||
with-env { FZF_DEFAULT_OPTS: $fzf_opts, FZF_DEFAULT_OPTS_FILE: '' } { ^($fzfcmd | first) ...$fzf_args }
|
||||
} else {
|
||||
let fzf_cmd_str = ($fzfcmd | str join ' ');
|
||||
let sh_cmd = [$alt_c_cmd '|' $fzf_cmd_str] | str join ' ';
|
||||
with-env { FZF_DEFAULT_OPTS: $fzf_opts, FZF_DEFAULT_OPTS_FILE: '' } { ^sh -c $sh_cmd }
|
||||
};
|
||||
if ($result | is-not-empty) { cd $result };
|
||||
"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# History
|
||||
const ctrl_r = {
|
||||
name: fzf_history
|
||||
modifier: control
|
||||
keycode: char_r
|
||||
mode: [emacs, vi_insert, vi_normal]
|
||||
event: [
|
||||
{
|
||||
send: executehostcommand
|
||||
cmd: "commandline edit --replace (
|
||||
let fzf_opts = (__fzf_defaults '' $'--scheme=history --bind=ctrl-r:toggle-sort --wrap-sign \"\t↳ \" --highlight-line ($env.FZF_CTRL_R_OPTS) +m --read0');
|
||||
let fzfcmd = (__fzfcmd);
|
||||
let fzf_args = ($fzfcmd | skip 1);
|
||||
# reverse | uniq: show most recent first, deduplicate keeping the latest.
|
||||
# Nushell's `history` loads the full history as an in-memory table
|
||||
# (bounded by $env.config.history.max_size, default 100,000), so
|
||||
# reverse and uniq run on an already-materialized list. This is O(n)
|
||||
# but acceptable for typical history sizes; unlike bash/zsh `fc -r`,
|
||||
# there is no streaming primitive that would let fzf show the latest
|
||||
# entries before the full list is consumed.
|
||||
history
|
||||
| get command
|
||||
| reverse
|
||||
| uniq
|
||||
| str join (char -i 0)
|
||||
| with-env { FZF_DEFAULT_OPTS: $fzf_opts, FZF_DEFAULT_OPTS_FILE: '' } { ^($fzfcmd | first) ...$fzf_args --query (commandline) }
|
||||
| decode utf-8
|
||||
| str trim
|
||||
)"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# Files
|
||||
const ctrl_t = {
|
||||
name: fzf_files
|
||||
modifier: control
|
||||
keycode: char_t
|
||||
mode: [emacs, vi_normal, vi_insert]
|
||||
event: [
|
||||
{
|
||||
send: executehostcommand
|
||||
cmd: "
|
||||
let fzf_opts = (__fzf_defaults '--reverse --walker=file,dir,follow,hidden --scheme=path' $'($env.FZF_CTRL_T_OPTS) -m');
|
||||
let fzfcmd = (__fzfcmd);
|
||||
let fzf_args = ($fzfcmd | skip 1);
|
||||
let ctrl_t_cmd = ($env.FZF_CTRL_T_COMMAND? | default null);
|
||||
let result = if ($ctrl_t_cmd == null) or ($ctrl_t_cmd | is-empty) {
|
||||
with-env { FZF_DEFAULT_OPTS: $fzf_opts, FZF_DEFAULT_OPTS_FILE: '' } { ^($fzfcmd | first) ...$fzf_args }
|
||||
} else {
|
||||
let fzf_cmd_str = ($fzfcmd | str join ' ');
|
||||
let sh_cmd = [$ctrl_t_cmd '|' $fzf_cmd_str] | str join ' ';
|
||||
with-env { FZF_DEFAULT_OPTS: $fzf_opts, FZF_DEFAULT_OPTS_FILE: '' } { ^sh -c $sh_cmd }
|
||||
};
|
||||
let result = ($result | str replace --all (char newline) ' ' | str trim);
|
||||
commandline edit --append $result;
|
||||
commandline set-cursor --end
|
||||
"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# Helper to check if a binding is enabled. A binding is disabled when
|
||||
# the corresponding *_COMMAND variable is explicitly set to "".
|
||||
# When not defined (null), the binding is enabled (using fzf's built-in walker).
|
||||
def __fzf_binding_enabled [var_name: string]: nothing -> bool {
|
||||
let val = ($env | get -o $var_name)
|
||||
# null = not defined = enabled; "" = explicitly disabled
|
||||
$val == null or ($val | into string | is-not-empty)
|
||||
}
|
||||
|
||||
# Update the $env.config
|
||||
export-env {
|
||||
let fzf_names = ['fzf_files', 'fzf_dirs', 'fzf_history']
|
||||
# Filter out any existing fzf bindings, then re-add the enabled ones.
|
||||
# This allows re-sourcing to update bindings (e.g. after changing
|
||||
# FZF_CTRL_T_COMMAND) without creating duplicates.
|
||||
mut bindings = ($env.config.keybindings | where { |kb| $kb.name not-in $fzf_names })
|
||||
if (__fzf_binding_enabled 'FZF_ALT_C_COMMAND') { $bindings = ($bindings | append $alt_c) }
|
||||
if (__fzf_binding_enabled 'FZF_CTRL_R_COMMAND') { $bindings = ($bindings | append $ctrl_r) }
|
||||
if (__fzf_binding_enabled 'FZF_CTRL_T_COMMAND') { $bindings = ($bindings | append $ctrl_t) }
|
||||
$env.config.keybindings = $bindings
|
||||
}
|
||||
@@ -110,8 +110,14 @@ fzf-cd-widget() {
|
||||
zle redisplay
|
||||
return 0
|
||||
fi
|
||||
# Use subshell expansion to get the absolute PWD of the target dir.
|
||||
# This allows the recorded shell history to be reused even from a different
|
||||
# working directory.
|
||||
# If failed, fallback to the unexpanded path to surface the error to the user.
|
||||
# NOTE: Don't use the `:a` modifier as it resolves symlinks like `pwd -P`.
|
||||
dir=$(builtin cd >/dev/null -- "${dir}" && echo "${PWD}" || echo "${dir}")
|
||||
zle push-line # Clear buffer. Auto-restored on next prompt.
|
||||
BUFFER="builtin cd -- ${(q)dir:a}"
|
||||
BUFFER="builtin cd -- ${(q)dir}"
|
||||
zle accept-line
|
||||
local ret=$?
|
||||
unset dir # ensure this doesn't end up appearing in prompt expansion
|
||||
|
||||
+5
-5
@@ -2,10 +2,10 @@
|
||||
|
||||
## What these functions do
|
||||
|
||||
`indexByteTwo(s []byte, b1, b2 byte) int` — returns the index of the
|
||||
`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
|
||||
`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
|
||||
@@ -91,9 +91,9 @@ implementations (`2xIndexByte` using `bytes.IndexByte`, and a simple `loop`).
|
||||
|
||||
The assembly is verified by three layers of testing:
|
||||
|
||||
1. **Table-driven tests** — known inputs with expected outputs.
|
||||
2. **Exhaustive tests** — all lengths 0–256, every match position, no-match
|
||||
1. **Table-driven tests** -- known inputs with expected outputs.
|
||||
2. **Exhaustive tests** -- all lengths 0–256, every match position, no-match
|
||||
cases, and both-bytes-present cases, compared against a simple loop
|
||||
reference.
|
||||
3. **Fuzz tests** — randomized inputs via `testing.F`, compared against the
|
||||
3. **Fuzz tests** -- randomized inputs via `testing.F`, compared against the
|
||||
same loop reference.
|
||||
|
||||
+2
-2
@@ -266,7 +266,7 @@ func charClassOf(char rune) charClass {
|
||||
}
|
||||
|
||||
func bonusFor(prevClass charClass, class charClass) int16 {
|
||||
if class > charNonWord {
|
||||
if class >= charNonWord {
|
||||
switch prevClass {
|
||||
case charWhite:
|
||||
// Word boundary after whitespace
|
||||
@@ -443,7 +443,7 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
|
||||
// we fall back to the greedy algorithm.
|
||||
// Also, we should not allow a very long pattern to avoid 16-bit integer
|
||||
// overflow in the score matrix. 1000 is a safe limit.
|
||||
if slab != nil && N*M > cap(slab.I16) || M > 1000 {
|
||||
if slab != nil && int64(N)*int64(M) > int64(cap(slab.I16)) || M > 1000 {
|
||||
return FuzzyMatchV1(caseSensitive, normalize, forward, input, pattern, withPos, slab)
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,15 @@ func TestFuzzyMatch(t *testing.T) {
|
||||
scoreMatch*4+int(bonusBoundaryDelimiter)*bonusFirstCharMultiplier+int(bonusBoundaryDelimiter)*3)
|
||||
assertMatch(t, fn, false, forward, "/.oh-my-zsh/cache", "zshc", 8, 13,
|
||||
scoreMatch*4+bonusBoundary*bonusFirstCharMultiplier+bonusBoundary*2+scoreGapStart+int(bonusBoundaryDelimiter))
|
||||
// Non-word character at start of input is treated as a strong boundary
|
||||
assertMatch(t, fn, false, forward, ".vimrc", ".vimrc", 0, 6,
|
||||
scoreMatch*6+int(bonusBoundaryWhite)*(bonusFirstCharMultiplier+5))
|
||||
// Non-word character right after a delimiter inherits the delimiter boundary
|
||||
assertMatch(t, fn, false, forward, "/.vimrc", ".vimrc", 1, 7,
|
||||
scoreMatch*6+int(bonusBoundaryDelimiter)*(bonusFirstCharMultiplier+5))
|
||||
// Non-word character in the middle of a word stays at bonusNonWord
|
||||
assertMatch(t, fn, false, forward, "a.vimrc", ".vimrc", 1, 7,
|
||||
scoreMatch*6+bonusBoundary*(bonusFirstCharMultiplier+5))
|
||||
assertMatch(t, fn, false, forward, "ab0123 456", "12356", 3, 10,
|
||||
scoreMatch*5+bonusConsecutive*3+scoreGapStart+scoreGapExtension)
|
||||
assertMatch(t, fn, false, forward, "abc123 456", "12356", 3, 10,
|
||||
|
||||
@@ -78,7 +78,7 @@ loop:
|
||||
CBZ R6, loop
|
||||
|
||||
end:
|
||||
// Found something or out of data — build full syndrome
|
||||
// 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
|
||||
|
||||
+1
-1
@@ -484,7 +484,7 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
|
||||
state.attr = state.attr | tui.Italic
|
||||
case 4:
|
||||
if sep == ':' {
|
||||
// SGR 4:N — underline style sub-parameter
|
||||
// SGR 4:N - underline style sub-parameter
|
||||
var subNum int
|
||||
subNum, _, ansiCode = parseAnsiCode(ansiCode)
|
||||
state.attr = state.attr &^ tui.UnderlineStyleMask
|
||||
|
||||
+5
-1
@@ -4,6 +4,7 @@ package fzf
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"math"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -241,6 +242,9 @@ func Run(opts *Options) (int, error) {
|
||||
denylist = make(map[int32]struct{})
|
||||
denyMutex.Unlock()
|
||||
}
|
||||
if opts.HeaderLines > math.MaxInt32 {
|
||||
opts.HeaderLines = math.MaxInt32
|
||||
}
|
||||
headerLines := int32(opts.HeaderLines)
|
||||
headerUpdated := false
|
||||
patternBuilder := func(runes []rune) *Pattern {
|
||||
@@ -467,7 +471,7 @@ func Run(opts *Options) (int, error) {
|
||||
terminal.UpdateCount(max(0, total-int(headerLines)), !reading, value.(*string))
|
||||
if headerLines > 0 && !headerUpdated {
|
||||
terminal.UpdateHeader(GetItems(snapshot, int(headerLines)))
|
||||
headerUpdated = int32(total) >= headerLines
|
||||
headerUpdated = total >= int(headerLines)
|
||||
}
|
||||
if heightUnknown && !deferred {
|
||||
determine(!reading)
|
||||
|
||||
+80
-21
@@ -4,12 +4,12 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"math"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/junegunn/fzf/src/algo"
|
||||
"github.com/junegunn/fzf/src/tui"
|
||||
@@ -85,7 +85,7 @@ Usage: fzf [options]
|
||||
--margin=MARGIN Screen margin (TRBL | TB,RL | T,RL,B | T,R,B,L)
|
||||
--padding=PADDING Padding inside border (TRBL | TB,RL | T,RL,B | T,R,B,L)
|
||||
--border[=STYLE] Draw border around the finder
|
||||
[rounded|sharp|bold|block|thinblock|double|horizontal|vertical|
|
||||
[rounded|sharp|bold|block|thinblock|double|dashed|horizontal|vertical|
|
||||
top|bottom|left|right|line|none] (default: rounded)
|
||||
--border-label=LABEL Label to print on the border
|
||||
--border-label-pos=COL Position of the border label
|
||||
@@ -128,7 +128,7 @@ Usage: fzf [options]
|
||||
(each for list section and preview window)
|
||||
--no-scrollbar Hide scrollbar
|
||||
--list-border[=STYLE] Draw border around the list section
|
||||
[rounded|sharp|bold|block|thinblock|double|horizontal|vertical|
|
||||
[rounded|sharp|bold|block|thinblock|double|dashed|horizontal|vertical|
|
||||
top|bottom|left|right|none] (default: rounded)
|
||||
--list-label=LABEL Label to print on the list border
|
||||
--list-label-pos=COL Position of the list label
|
||||
@@ -148,7 +148,7 @@ Usage: fzf [options]
|
||||
--ghost=TEXT Ghost text to display when the input is empty
|
||||
--filepath-word Make word-wise movements respect path separators
|
||||
--input-border[=STYLE] Draw border around the input section
|
||||
[rounded|sharp|bold|block|thinblock|double|horizontal|vertical|
|
||||
[rounded|sharp|bold|block|thinblock|double|dashed|horizontal|vertical|
|
||||
top|bottom|left|right|line|none] (default: rounded)
|
||||
--input-label=LABEL Label to print on the input border
|
||||
--input-label-pos=COL Position of the input label
|
||||
@@ -159,13 +159,13 @@ Usage: fzf [options]
|
||||
PREVIEW WINDOW
|
||||
--preview=COMMAND Command to preview highlighted line ({})
|
||||
--preview-window=OPT Preview window layout (default: right:50%)
|
||||
[up|down|left|right][,SIZE[%]]
|
||||
[up|down|left|right|next][,SIZE[%]]
|
||||
[,[no]wrap[-word]][,[no]cycle][,[no]follow][,[no]info]
|
||||
[,[no]hidden][,border-STYLE]
|
||||
[,+SCROLL[OFFSETS][/DENOM]][,~HEADER_LINES]
|
||||
[,default][,<SIZE_THRESHOLD(ALTERNATIVE_LAYOUT)]
|
||||
--preview-border[=STYLE] Short for --preview-window=border-STYLE
|
||||
[rounded|sharp|bold|block|thinblock|double|horizontal|vertical|
|
||||
[rounded|sharp|bold|block|thinblock|double|dashed|horizontal|vertical|
|
||||
top|bottom|left|right|line|none] (default: rounded)
|
||||
--preview-label=LABEL
|
||||
--preview-label-pos=N Same as --border-label and --border-label-pos,
|
||||
@@ -177,11 +177,12 @@ Usage: fzf [options]
|
||||
--header-lines=N The first N lines of the input are treated as header
|
||||
--header-first Print header before the prompt line
|
||||
--header-border[=STYLE] Draw border around the header section
|
||||
[rounded|sharp|bold|block|thinblock|double|horizontal|vertical|
|
||||
top|bottom|left|right|line|none] (default: rounded)
|
||||
[rounded|sharp|bold|block|thinblock|double|dashed|horizontal|vertical|
|
||||
top|bottom|left|right|line|inline|none] (default: rounded)
|
||||
--header-lines-border[=STYLE]
|
||||
Display header from --header-lines with a separate border.
|
||||
Pass 'none' to still separate it but without a border.
|
||||
Pass 'inline' to embed it inside the list frame.
|
||||
--header-label=LABEL Label to print on the header border
|
||||
--header-label-pos=COL Position of the header label
|
||||
[POSITIVE_INTEGER: columns from left|
|
||||
@@ -191,8 +192,8 @@ Usage: fzf [options]
|
||||
FOOTER
|
||||
--footer=STR String to print as footer
|
||||
--footer-border[=STYLE] Draw border around the footer section
|
||||
[rounded|sharp|bold|block|thinblock|double|horizontal|vertical|
|
||||
top|bottom|left|right|line|none] (default: line)
|
||||
[rounded|sharp|bold|block|thinblock|double|dashed|horizontal|vertical|
|
||||
top|bottom|left|right|line|inline|none] (default: line)
|
||||
--footer-label=LABEL Label to print on the footer border
|
||||
--footer-label-pos=COL Position of the footer label
|
||||
[POSITIVE_INTEGER: columns from left|
|
||||
@@ -231,6 +232,7 @@ Usage: fzf [options]
|
||||
--bash Print script to set up Bash shell integration
|
||||
--zsh Print script to set up Zsh shell integration
|
||||
--fish Print script to set up Fish shell integration
|
||||
--nushell Print script to set up Nushell integration
|
||||
|
||||
HELP
|
||||
--version Display version information and exit
|
||||
@@ -330,6 +332,7 @@ const (
|
||||
posLeft
|
||||
posRight
|
||||
posCenter
|
||||
posNext // adjacent to the input section, on the list side
|
||||
)
|
||||
|
||||
type tmuxOptions struct {
|
||||
@@ -389,7 +392,7 @@ func (o *previewOpts) Toggle() {
|
||||
o.hidden = !o.hidden
|
||||
}
|
||||
|
||||
func (o *previewOpts) Border() tui.BorderShape {
|
||||
func (o *previewOpts) Border(layout layoutType) tui.BorderShape {
|
||||
shape := o.border
|
||||
if shape == tui.BorderLine {
|
||||
switch o.position {
|
||||
@@ -401,6 +404,12 @@ func (o *previewOpts) Border() tui.BorderShape {
|
||||
shape = tui.BorderRight
|
||||
case posRight:
|
||||
shape = tui.BorderLeft
|
||||
case posNext:
|
||||
if layout == layoutReverse {
|
||||
shape = tui.BorderBottom
|
||||
} else {
|
||||
shape = tui.BorderTop
|
||||
}
|
||||
}
|
||||
}
|
||||
return shape
|
||||
@@ -510,7 +519,7 @@ func parseLabelPosition(opts *labelOpts, arg string) error {
|
||||
}
|
||||
|
||||
func (a previewOpts) aboveOrBelow() bool {
|
||||
return a.size.size > 0 && (a.position == posUp || a.position == posDown)
|
||||
return a.size.size > 0 && (a.position == posUp || a.position == posDown || a.position == posNext)
|
||||
}
|
||||
|
||||
type previewOptsCompare int
|
||||
@@ -577,6 +586,7 @@ type Options struct {
|
||||
Bash bool
|
||||
Zsh bool
|
||||
Fish bool
|
||||
Nushell bool
|
||||
Man bool
|
||||
Fuzzy bool
|
||||
FuzzyAlgo algo.Algo
|
||||
@@ -724,6 +734,7 @@ func defaultOptions() *Options {
|
||||
Bash: false,
|
||||
Zsh: false,
|
||||
Fish: false,
|
||||
Nushell: false,
|
||||
Man: false,
|
||||
Fuzzy: true,
|
||||
FuzzyAlgo: algo.FuzzyMatchV2,
|
||||
@@ -953,6 +964,8 @@ func parseBorder(str string, optional bool) (tui.BorderShape, error) {
|
||||
switch str {
|
||||
case "line":
|
||||
return tui.BorderLine, nil
|
||||
case "inline":
|
||||
return tui.BorderInline, nil
|
||||
case "rounded":
|
||||
return tui.BorderRounded, nil
|
||||
case "sharp":
|
||||
@@ -965,6 +978,8 @@ func parseBorder(str string, optional bool) (tui.BorderShape, error) {
|
||||
return tui.BorderThinBlock, nil
|
||||
case "double":
|
||||
return tui.BorderDouble, nil
|
||||
case "dashed":
|
||||
return tui.BorderDashed, nil
|
||||
case "horizontal":
|
||||
return tui.BorderHorizontal, nil
|
||||
case "vertical":
|
||||
@@ -983,7 +998,7 @@ func parseBorder(str string, optional bool) (tui.BorderShape, error) {
|
||||
if optional && str == "" {
|
||||
return defaultBorderShape, nil
|
||||
}
|
||||
return tui.BorderNone, errors.New("invalid border style (expected: rounded|sharp|bold|block|thinblock|double|horizontal|vertical|top|bottom|left|right|none)")
|
||||
return tui.BorderNone, errors.New("invalid border style (expected: rounded|sharp|bold|block|thinblock|double|dashed|horizontal|vertical|top|bottom|left|right|line|inline|none)")
|
||||
}
|
||||
|
||||
func parseKeyChords(str string, message string) (map[tui.Event]string, []tui.Event, error) {
|
||||
@@ -1252,7 +1267,14 @@ func parseKeyChords(str string, message string) (map[tui.Event]string, []tui.Eve
|
||||
add(tui.F12)
|
||||
default:
|
||||
runes := []rune(key)
|
||||
if len(key) == 10 && strings.HasPrefix(lkey, "ctrl-alt-") && isAlphabet(lkey[9]) {
|
||||
if strings.HasPrefix(lkey, "every(") && strings.HasSuffix(lkey, ")") {
|
||||
evt, err := parseEveryEvent(key[6 : len(key)-1])
|
||||
if err != nil {
|
||||
return nil, list, err
|
||||
}
|
||||
chords[evt] = key
|
||||
list = append(list, evt)
|
||||
} else if len(key) == 10 && strings.HasPrefix(lkey, "ctrl-alt-") && isAlphabet(lkey[9]) {
|
||||
r := rune(lkey[9])
|
||||
evt := tui.CtrlAltKey(r)
|
||||
if r == 'h' && !util.IsWindows() {
|
||||
@@ -1294,6 +1316,21 @@ func parseKeyChords(str string, message string) (map[tui.Event]string, []tui.Eve
|
||||
return chords, list, nil
|
||||
}
|
||||
|
||||
func parseEveryEvent(arg string) (tui.Event, error) {
|
||||
secs, err := strconv.ParseFloat(strings.TrimSpace(arg), 64)
|
||||
if err != nil || math.IsNaN(secs) || math.IsInf(secs, 0) || secs <= 0 {
|
||||
return tui.Event{}, errors.New("every() requires a positive number of seconds")
|
||||
}
|
||||
if secs < 0.01 {
|
||||
secs = 0.01
|
||||
}
|
||||
ms := math.Round(secs * 1000)
|
||||
if ms > math.MaxInt32 {
|
||||
return tui.Event{}, errors.New("every() interval is too large")
|
||||
}
|
||||
return tui.Event{Type: tui.Every, Char: rune(int32(ms))}, nil
|
||||
}
|
||||
|
||||
func parseScheme(str string) (string, []criterion, error) {
|
||||
str = strings.ToLower(str)
|
||||
switch str {
|
||||
@@ -1567,7 +1604,7 @@ func parseTheme(defaultTheme *tui.ColorTheme, str string) (*tui.ColorTheme, *tui
|
||||
case "info":
|
||||
mergeAttr(&theme.Info)
|
||||
case "pointer":
|
||||
mergeAttr(&theme.Cursor)
|
||||
mergeAttr(&theme.Pointer)
|
||||
case "marker":
|
||||
mergeAttr(&theme.Marker)
|
||||
case "header", "header-fg":
|
||||
@@ -1696,10 +1733,10 @@ Loop:
|
||||
return masked
|
||||
}
|
||||
|
||||
func parseSingleActionList(str string) ([]*action, error) {
|
||||
func parseSingleActionList(str string, putAllowed bool) ([]*action, error) {
|
||||
// We prepend a colon to satisfy argActionRegexp and remove it later
|
||||
masked := maskActionContents(":" + str)[1:]
|
||||
return parseActionList(masked, str, []*action{}, false)
|
||||
return parseActionList(masked, str, []*action{}, putAllowed)
|
||||
}
|
||||
|
||||
func parseActionList(masked string, original string, prevActions []*action, putAllowed bool) ([]*action, error) {
|
||||
@@ -2005,8 +2042,7 @@ func parseKeymap(keymap map[tui.Event][]*action, str string) error {
|
||||
}
|
||||
key = firstKey(keys)
|
||||
}
|
||||
putAllowed := key.Type == tui.Rune && unicode.IsGraphic(key.Char)
|
||||
keymap[key], err = parseActionList(pair[1], origPairStr[len(pair[0])+1:], keymap[key], putAllowed)
|
||||
keymap[key], err = parseActionList(pair[1], origPairStr[len(pair[0])+1:], keymap[key], key.Printable())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -2324,6 +2360,8 @@ func parsePreviewWindowImpl(opts *previewOpts, input string) error {
|
||||
opts.position = posLeft
|
||||
case "right":
|
||||
opts.position = posRight
|
||||
case "next":
|
||||
opts.position = posNext
|
||||
case "rounded", "border", "border-rounded":
|
||||
opts.border = tui.BorderRounded
|
||||
case "border-line":
|
||||
@@ -2338,6 +2376,8 @@ func parsePreviewWindowImpl(opts *previewOpts, input string) error {
|
||||
opts.border = tui.BorderThinBlock
|
||||
case "border-double":
|
||||
opts.border = tui.BorderDouble
|
||||
case "border-dashed":
|
||||
opts.border = tui.BorderDashed
|
||||
case "noborder", "border-none":
|
||||
opts.border = tui.BorderNone
|
||||
case "border-horizontal":
|
||||
@@ -2514,6 +2554,7 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
|
||||
opts.Bash = false
|
||||
opts.Zsh = false
|
||||
opts.Fish = false
|
||||
opts.Nushell = false
|
||||
opts.Help = false
|
||||
opts.Version = false
|
||||
opts.Man = false
|
||||
@@ -2626,6 +2667,9 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
|
||||
case "--fish":
|
||||
clearExitingOpts()
|
||||
opts.Fish = true
|
||||
case "--nushell":
|
||||
clearExitingOpts()
|
||||
opts.Nushell = true
|
||||
case "-h", "--help":
|
||||
clearExitingOpts()
|
||||
opts.Help = true
|
||||
@@ -3128,7 +3172,7 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
|
||||
case "--no-preview":
|
||||
opts.Preview.command = ""
|
||||
case "--preview-window":
|
||||
str, err := nextString("preview window layout required: [up|down|left|right][,SIZE[%]][,border-STYLE][,wrap][,cycle][,hidden][,+SCROLL[OFFSETS][/DENOM]][,~HEADER_LINES][,default]")
|
||||
str, err := nextString("preview window layout required: [up|down|left|right|next][,SIZE[%]][,border-STYLE][,wrap][,cycle][,hidden][,+SCROLL[OFFSETS][/DENOM]][,~HEADER_LINES][,default]")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -3516,7 +3560,9 @@ func applyPreset(opts *Options, preset string) error {
|
||||
opts.Preview.border = tui.BorderLine
|
||||
opts.Preview.info = false
|
||||
opts.InfoStyle = infoDefault
|
||||
opts.Theme.Gutter = tui.ColorAttr{Color: -1, Attr: 0}
|
||||
opts.Theme.Gutter = tui.NewColorAttr()
|
||||
space := " "
|
||||
opts.Gutter = &space
|
||||
empty := ""
|
||||
opts.Separator = &empty
|
||||
opts.Scrollbar = &empty
|
||||
@@ -3608,6 +3654,19 @@ func validateOptions(opts *Options) error {
|
||||
return errors.New("only ANSI attributes are allowed for 'nth' (regular, bold, underline, reverse, dim, italic, strikethrough)")
|
||||
}
|
||||
|
||||
if opts.BorderShape == tui.BorderInline ||
|
||||
opts.ListBorderShape == tui.BorderInline ||
|
||||
opts.InputBorderShape == tui.BorderInline ||
|
||||
opts.Preview.border == tui.BorderInline {
|
||||
return errors.New("inline border is only supported for --header-border, --header-lines-border, and --footer-border")
|
||||
}
|
||||
if opts.HeaderBorderShape == tui.BorderInline &&
|
||||
opts.HeaderLinesShape != tui.BorderInline &&
|
||||
opts.HeaderLinesShape != tui.BorderUndefined &&
|
||||
opts.HeaderLinesShape != tui.BorderNone {
|
||||
return errors.New("--header-border=inline requires --header-lines-border to be inline or unset")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+35
-2
@@ -299,6 +299,39 @@ func TestBind(t *testing.T) {
|
||||
check(tui.F1.AsEvent(), "", actAbort)
|
||||
}
|
||||
|
||||
func TestParseEveryEvent(t *testing.T) {
|
||||
pairs, _, err := parseKeyChords("every(2),every(0.5)", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(pairs) != 2 {
|
||||
t.Errorf("expected 2 distinct every events, got %d", len(pairs))
|
||||
}
|
||||
if pairs[(tui.Event{Type: tui.Every, Char: 2000})] != "every(2)" {
|
||||
t.Errorf("every(2) not registered")
|
||||
}
|
||||
if pairs[(tui.Event{Type: tui.Every, Char: 500})] != "every(0.5)" {
|
||||
t.Errorf("every(0.5) not registered")
|
||||
}
|
||||
|
||||
// Floor at 0.01s -> 10ms
|
||||
pairs, _, err = parseKeyChords("every(0.001)", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if pairs[(tui.Event{Type: tui.Every, Char: 10})] != "every(0.001)" {
|
||||
t.Errorf("every(0.001) should floor to 10ms")
|
||||
}
|
||||
|
||||
// Reject zero, negatives, and overflow (>= 2^31 ms = ~24.85 days)
|
||||
for _, bad := range []string{"every(0)", "every(-1)", "every(abc)", "every()", "every(2147484)"} {
|
||||
if _, _, err := parseKeyChords(bad, ""); err == nil {
|
||||
t.Errorf("%s should be rejected", bad)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestColorSpec(t *testing.T) {
|
||||
var base *tui.ColorTheme
|
||||
theme := tui.Dark256
|
||||
@@ -539,7 +572,7 @@ func TestValidateSign(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestParseSingleActionList(t *testing.T) {
|
||||
actions, _ := parseSingleActionList("Execute@foo+bar,baz@+up+up+reload:down+down")
|
||||
actions, _ := parseSingleActionList("Execute@foo+bar,baz@+up+up+reload:down+down", false)
|
||||
if len(actions) != 4 {
|
||||
t.Errorf("Invalid number of actions parsed:%d", len(actions))
|
||||
}
|
||||
@@ -555,7 +588,7 @@ func TestParseSingleActionList(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestParseSingleActionListError(t *testing.T) {
|
||||
_, err := parseSingleActionList("change-query(foobar)baz")
|
||||
_, err := parseSingleActionList("change-query(foobar)baz", false)
|
||||
if err == nil {
|
||||
t.Errorf("Failed to detect error")
|
||||
}
|
||||
|
||||
+21
-18
@@ -2,6 +2,7 @@ package fzf
|
||||
|
||||
import (
|
||||
"math"
|
||||
"slices"
|
||||
"sort"
|
||||
"unicode"
|
||||
|
||||
@@ -30,7 +31,7 @@ type Result struct {
|
||||
|
||||
func buildResult(item *Item, offsets []Offset, score int) Result {
|
||||
if len(offsets) > 1 {
|
||||
sort.Sort(ByOrder(offsets))
|
||||
slices.SortFunc(offsets, compareOffsets)
|
||||
}
|
||||
|
||||
minBegin := math.MaxUint16
|
||||
@@ -187,7 +188,7 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
|
||||
}
|
||||
}
|
||||
|
||||
// sort.Sort(ByOrder(offsets))
|
||||
// slices.SortFunc(offsets, compareOffsets)
|
||||
|
||||
// Merge offsets
|
||||
// ------------ ---- -- ----
|
||||
@@ -197,7 +198,10 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
|
||||
start := 0
|
||||
ansiToColorPair := func(ansi ansiOffset, base tui.ColorPair) tui.ColorPair {
|
||||
if !theme.Colored {
|
||||
return tui.NewColorPair(-1, -1, ansi.color.attr).MergeAttr(base)
|
||||
// Ignore ANSI colors but keep the attributes. Retain the base
|
||||
// colors (e.g. an overridden input-bg or list-bg) instead of
|
||||
// resetting to the terminal default.
|
||||
return tui.NewColorPair(base.Fg(), base.Bg(), ansi.color.attr).MergeAttr(base)
|
||||
}
|
||||
// fd --color always | fzf --ansi --delimiter / --nth -1 --color fg:dim:strip,nth:regular
|
||||
if base.ShouldStripColors() {
|
||||
@@ -297,21 +301,20 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
|
||||
return colors
|
||||
}
|
||||
|
||||
// ByOrder is for sorting substring offsets
|
||||
type ByOrder []Offset
|
||||
|
||||
func (a ByOrder) Len() int {
|
||||
return len(a)
|
||||
}
|
||||
|
||||
func (a ByOrder) Swap(i, j int) {
|
||||
a[i], a[j] = a[j], a[i]
|
||||
}
|
||||
|
||||
func (a ByOrder) Less(i, j int) bool {
|
||||
ioff := a[i]
|
||||
joff := a[j]
|
||||
return (ioff[0] < joff[0]) || (ioff[0] == joff[0]) && (ioff[1] <= joff[1])
|
||||
func compareOffsets(a, b Offset) int {
|
||||
if a[0] < b[0] {
|
||||
return -1
|
||||
}
|
||||
if a[0] > b[0] {
|
||||
return 1
|
||||
}
|
||||
if a[1] < b[1] {
|
||||
return -1
|
||||
}
|
||||
if a[1] > b[1] {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ByRelevance is for sorting Items
|
||||
|
||||
+2
-1
@@ -3,6 +3,7 @@ package fzf
|
||||
import (
|
||||
"math"
|
||||
"math/rand"
|
||||
"slices"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
@@ -19,7 +20,7 @@ func TestOffsetSort(t *testing.T) {
|
||||
offsets := []Offset{
|
||||
{3, 5}, {2, 7},
|
||||
{1, 3}, {2, 9}}
|
||||
sort.Sort(ByOrder(offsets))
|
||||
slices.SortFunc(offsets, compareOffsets)
|
||||
|
||||
if offsets[0][0] != 1 || offsets[0][1] != 3 ||
|
||||
offsets[1][0] != 2 || offsets[1][1] != 7 ||
|
||||
|
||||
+5
-4
@@ -153,7 +153,7 @@ func startHttpServer(address listenAddress, actionChannel chan []*action, getHan
|
||||
func (server *httpServer) handleHttpRequest(conn net.Conn) string {
|
||||
contentLength := 0
|
||||
apiKey := ""
|
||||
body := ""
|
||||
var bodyBuilder strings.Builder
|
||||
answer := func(code string, message string) string {
|
||||
message += "\n"
|
||||
return code + fmt.Sprintf("Content-Length: %d%s", len(message), crlf+crlf+message)
|
||||
@@ -175,7 +175,7 @@ func (server *httpServer) handleHttpRequest(conn net.Conn) string {
|
||||
token := data[:found+len(crlf)]
|
||||
return len(token), token, nil
|
||||
}
|
||||
if atEOF || len(body)+len(data) >= contentLength {
|
||||
if atEOF || bodyBuilder.Len()+len(data) >= contentLength {
|
||||
return 0, data, bufio.ErrFinalToken
|
||||
}
|
||||
return 0, nil, nil
|
||||
@@ -218,7 +218,7 @@ Loop:
|
||||
}
|
||||
}
|
||||
case 2: // Request body
|
||||
body += text
|
||||
bodyBuilder.WriteString(text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,12 +234,13 @@ Loop:
|
||||
return answer(httpUnavailable+jsonContentType, `{"error":"timeout"}`)
|
||||
}
|
||||
|
||||
body := bodyBuilder.String()
|
||||
if len(body) < contentLength {
|
||||
return bad("incomplete request")
|
||||
}
|
||||
body = body[:contentLength]
|
||||
|
||||
actions, err := parseSingleActionList(strings.Trim(string(body), "\r\n"))
|
||||
actions, err := parseSingleActionList(strings.Trim(string(body), "\r\n"), false)
|
||||
if err != nil {
|
||||
return bad(err.Error())
|
||||
}
|
||||
|
||||
+626
-204
File diff suppressed because it is too large
Load Diff
@@ -721,7 +721,7 @@ func TestWordWrapAnsiLine(t *testing.T) {
|
||||
t.Errorf("ANSI: %q", result)
|
||||
}
|
||||
|
||||
// Long word (no space) — no break, let character wrapping handle it
|
||||
// Long word (no space) - no break, let character wrapping handle it
|
||||
result = term.wordWrapAnsiLine("abcdefghij", 5, 2)
|
||||
if len(result) != 1 || result[0] != "abcdefghij" {
|
||||
t.Errorf("Long word: %q", result)
|
||||
@@ -749,7 +749,7 @@ func TestWordWrapAnsiLine(t *testing.T) {
|
||||
|
||||
// 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
|
||||
// "\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" {
|
||||
|
||||
+19
-18
@@ -133,22 +133,22 @@ func _() {
|
||||
_ = x[CtrlAltShiftDelete-122]
|
||||
_ = x[CtrlAltShiftPageUp-123]
|
||||
_ = x[CtrlAltShiftPageDown-124]
|
||||
_ = x[Invalid-125]
|
||||
_ = x[Fatal-126]
|
||||
_ = x[BracketedPasteBegin-127]
|
||||
_ = x[BracketedPasteEnd-128]
|
||||
_ = x[Mouse-129]
|
||||
_ = x[DoubleClick-130]
|
||||
_ = x[LeftClick-131]
|
||||
_ = x[RightClick-132]
|
||||
_ = x[SLeftClick-133]
|
||||
_ = x[SRightClick-134]
|
||||
_ = x[ScrollUp-135]
|
||||
_ = x[ScrollDown-136]
|
||||
_ = x[SScrollUp-137]
|
||||
_ = x[SScrollDown-138]
|
||||
_ = x[PreviewScrollUp-139]
|
||||
_ = x[PreviewScrollDown-140]
|
||||
_ = x[Mouse-125]
|
||||
_ = x[DoubleClick-126]
|
||||
_ = x[LeftClick-127]
|
||||
_ = x[RightClick-128]
|
||||
_ = x[SLeftClick-129]
|
||||
_ = x[SRightClick-130]
|
||||
_ = x[ScrollUp-131]
|
||||
_ = x[ScrollDown-132]
|
||||
_ = x[SScrollUp-133]
|
||||
_ = x[SScrollDown-134]
|
||||
_ = x[PreviewScrollUp-135]
|
||||
_ = x[PreviewScrollDown-136]
|
||||
_ = x[Invalid-137]
|
||||
_ = x[Fatal-138]
|
||||
_ = x[BracketedPasteBegin-139]
|
||||
_ = x[BracketedPasteEnd-140]
|
||||
_ = x[Resize-141]
|
||||
_ = x[Change-142]
|
||||
_ = x[BackwardEOF-143]
|
||||
@@ -163,11 +163,12 @@ func _() {
|
||||
_ = x[ClickHeader-152]
|
||||
_ = x[ClickFooter-153]
|
||||
_ = x[Multi-154]
|
||||
_ = x[Every-155]
|
||||
}
|
||||
|
||||
const _EventType_name = "RuneCtrlACtrlBCtrlCCtrlDCtrlECtrlFCtrlGCtrlHTabCtrlJCtrlKCtrlLEnterCtrlNCtrlOCtrlPCtrlQCtrlRCtrlSCtrlTCtrlUCtrlVCtrlWCtrlXCtrlYCtrlZEscCtrlSpaceCtrlBackSlashCtrlRightBracketCtrlCaretCtrlSlashShiftTabBackspaceDeletePageUpPageDownUpDownLeftRightHomeEndInsertShiftUpShiftDownShiftLeftShiftRightShiftDeleteShiftHomeShiftEndShiftPageUpShiftPageDownF1F2F3F4F5F6F7F8F9F10F11F12AltBackspaceAltUpAltDownAltLeftAltRightAltDeleteAltHomeAltEndAltPageUpAltPageDownAltShiftUpAltShiftDownAltShiftLeftAltShiftRightAltShiftDeleteAltShiftHomeAltShiftEndAltShiftPageUpAltShiftPageDownCtrlUpCtrlDownCtrlLeftCtrlRightCtrlHomeCtrlEndCtrlBackspaceCtrlDeleteCtrlPageUpCtrlPageDownAltCtrlAltCtrlAltUpCtrlAltDownCtrlAltLeftCtrlAltRightCtrlAltHomeCtrlAltEndCtrlAltBackspaceCtrlAltDeleteCtrlAltPageUpCtrlAltPageDownCtrlShiftUpCtrlShiftDownCtrlShiftLeftCtrlShiftRightCtrlShiftHomeCtrlShiftEndCtrlShiftDeleteCtrlShiftPageUpCtrlShiftPageDownCtrlAltShiftUpCtrlAltShiftDownCtrlAltShiftLeftCtrlAltShiftRightCtrlAltShiftHomeCtrlAltShiftEndCtrlAltShiftDeleteCtrlAltShiftPageUpCtrlAltShiftPageDownInvalidFatalBracketedPasteBeginBracketedPasteEndMouseDoubleClickLeftClickRightClickSLeftClickSRightClickScrollUpScrollDownSScrollUpSScrollDownPreviewScrollUpPreviewScrollDownResizeChangeBackwardEOFStartLoadFocusOneZeroResultJumpJumpCancelClickHeaderClickFooterMulti"
|
||||
const _EventType_name = "RuneCtrlACtrlBCtrlCCtrlDCtrlECtrlFCtrlGCtrlHTabCtrlJCtrlKCtrlLEnterCtrlNCtrlOCtrlPCtrlQCtrlRCtrlSCtrlTCtrlUCtrlVCtrlWCtrlXCtrlYCtrlZEscCtrlSpaceCtrlBackSlashCtrlRightBracketCtrlCaretCtrlSlashShiftTabBackspaceDeletePageUpPageDownUpDownLeftRightHomeEndInsertShiftUpShiftDownShiftLeftShiftRightShiftDeleteShiftHomeShiftEndShiftPageUpShiftPageDownF1F2F3F4F5F6F7F8F9F10F11F12AltBackspaceAltUpAltDownAltLeftAltRightAltDeleteAltHomeAltEndAltPageUpAltPageDownAltShiftUpAltShiftDownAltShiftLeftAltShiftRightAltShiftDeleteAltShiftHomeAltShiftEndAltShiftPageUpAltShiftPageDownCtrlUpCtrlDownCtrlLeftCtrlRightCtrlHomeCtrlEndCtrlBackspaceCtrlDeleteCtrlPageUpCtrlPageDownAltCtrlAltCtrlAltUpCtrlAltDownCtrlAltLeftCtrlAltRightCtrlAltHomeCtrlAltEndCtrlAltBackspaceCtrlAltDeleteCtrlAltPageUpCtrlAltPageDownCtrlShiftUpCtrlShiftDownCtrlShiftLeftCtrlShiftRightCtrlShiftHomeCtrlShiftEndCtrlShiftDeleteCtrlShiftPageUpCtrlShiftPageDownCtrlAltShiftUpCtrlAltShiftDownCtrlAltShiftLeftCtrlAltShiftRightCtrlAltShiftHomeCtrlAltShiftEndCtrlAltShiftDeleteCtrlAltShiftPageUpCtrlAltShiftPageDownMouseDoubleClickLeftClickRightClickSLeftClickSRightClickScrollUpScrollDownSScrollUpSScrollDownPreviewScrollUpPreviewScrollDownInvalidFatalBracketedPasteBeginBracketedPasteEndResizeChangeBackwardEOFStartLoadFocusOneZeroResultJumpJumpCancelClickHeaderClickFooterMultiEvery"
|
||||
|
||||
var _EventType_index = [...]uint16{0, 4, 9, 14, 19, 24, 29, 34, 39, 44, 47, 52, 57, 62, 67, 72, 77, 82, 87, 92, 97, 102, 107, 112, 117, 122, 127, 132, 135, 144, 157, 173, 182, 191, 199, 208, 214, 220, 228, 230, 234, 238, 243, 247, 250, 256, 263, 272, 281, 291, 302, 311, 319, 330, 343, 345, 347, 349, 351, 353, 355, 357, 359, 361, 364, 367, 370, 382, 387, 394, 401, 409, 418, 425, 431, 440, 451, 461, 473, 485, 498, 512, 524, 535, 549, 565, 571, 579, 587, 596, 604, 611, 624, 634, 644, 656, 659, 666, 675, 686, 697, 709, 720, 730, 746, 759, 772, 787, 798, 811, 824, 838, 851, 863, 878, 893, 910, 924, 940, 956, 973, 989, 1004, 1022, 1040, 1060, 1067, 1072, 1091, 1108, 1113, 1124, 1133, 1143, 1153, 1164, 1172, 1182, 1191, 1202, 1217, 1234, 1240, 1246, 1257, 1262, 1266, 1271, 1274, 1278, 1284, 1288, 1298, 1309, 1320, 1325}
|
||||
var _EventType_index = [...]uint16{0, 4, 9, 14, 19, 24, 29, 34, 39, 44, 47, 52, 57, 62, 67, 72, 77, 82, 87, 92, 97, 102, 107, 112, 117, 122, 127, 132, 135, 144, 157, 173, 182, 191, 199, 208, 214, 220, 228, 230, 234, 238, 243, 247, 250, 256, 263, 272, 281, 291, 302, 311, 319, 330, 343, 345, 347, 349, 351, 353, 355, 357, 359, 361, 364, 367, 370, 382, 387, 394, 401, 409, 418, 425, 431, 440, 451, 461, 473, 485, 498, 512, 524, 535, 549, 565, 571, 579, 587, 596, 604, 611, 624, 634, 644, 656, 659, 666, 675, 686, 697, 709, 720, 730, 746, 759, 772, 787, 798, 811, 824, 838, 851, 863, 878, 893, 910, 924, 940, 956, 973, 989, 1004, 1022, 1040, 1060, 1065, 1076, 1085, 1095, 1105, 1116, 1124, 1134, 1143, 1154, 1169, 1186, 1193, 1198, 1217, 1234, 1240, 1246, 1257, 1262, 1266, 1271, 1274, 1278, 1284, 1288, 1298, 1309, 1320, 1325, 1330}
|
||||
|
||||
func (i EventType) String() string {
|
||||
if i < 0 || i >= EventType(len(_EventType_index)-1) {
|
||||
|
||||
+135
-110
@@ -67,7 +67,8 @@ func (r *LightRenderer) stderrInternal(str string, allowNLCR bool, resetCode str
|
||||
for len(bytes) > 0 {
|
||||
r, sz := utf8.DecodeRune(bytes)
|
||||
nlcr := r == '\n' || r == '\r'
|
||||
if r >= 32 || r == '\x1b' || nlcr {
|
||||
isC1 := r >= 0x80 && r <= 0x9F
|
||||
if (r >= 32 && !isC1) || r == '\x1b' || nlcr {
|
||||
if nlcr && !allowNLCR {
|
||||
if r == '\r' {
|
||||
runes = append(runes, []rune(CR+resetCode)...)
|
||||
@@ -689,6 +690,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
switch r.buffer[4] {
|
||||
case '1', '2', '3', '4', '5', '6', '7', '8', '9':
|
||||
// Kitty iTerm2 WezTerm
|
||||
// ARROW "\e[1;1D"
|
||||
// SHIFT-ARROW "\e[1;2D"
|
||||
// ALT-SHIFT-ARROW "\e[1;4D" "\e[1;10D" "\e[1;4D"
|
||||
// CTRL-SHIFT-ARROW "\e[1;6D" N/A
|
||||
@@ -743,6 +745,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
if shift {
|
||||
return Event{ShiftUp, 0, nil}
|
||||
}
|
||||
return Event{Up, 0, nil}
|
||||
case 'B':
|
||||
if ctrlAltShift {
|
||||
return Event{CtrlAltShiftDown, 0, nil}
|
||||
@@ -765,6 +768,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
if shift {
|
||||
return Event{ShiftDown, 0, nil}
|
||||
}
|
||||
return Event{Down, 0, nil}
|
||||
case 'C':
|
||||
if ctrlAltShift {
|
||||
return Event{CtrlAltShiftRight, 0, nil}
|
||||
@@ -787,6 +791,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
if alt {
|
||||
return Event{AltRight, 0, nil}
|
||||
}
|
||||
return Event{Right, 0, nil}
|
||||
case 'D':
|
||||
if ctrlAltShift {
|
||||
return Event{CtrlAltShiftLeft, 0, nil}
|
||||
@@ -809,6 +814,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
if shift {
|
||||
return Event{ShiftLeft, 0, nil}
|
||||
}
|
||||
return Event{Left, 0, nil}
|
||||
case 'H':
|
||||
if ctrlAltShift {
|
||||
return Event{CtrlAltShiftHome, 0, nil}
|
||||
@@ -831,6 +837,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
if shift {
|
||||
return Event{ShiftHome, 0, nil}
|
||||
}
|
||||
return Event{Home, 0, nil}
|
||||
case 'F':
|
||||
if ctrlAltShift {
|
||||
return Event{CtrlAltShiftEnd, 0, nil}
|
||||
@@ -853,6 +860,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
if shift {
|
||||
return Event{ShiftEnd, 0, nil}
|
||||
}
|
||||
return Event{End, 0, nil}
|
||||
}
|
||||
} // r.buffer[4]
|
||||
} // r.buffer[3]
|
||||
@@ -1122,127 +1130,144 @@ func (w *LightWindow) DrawHBorder() {
|
||||
w.drawBorder(true)
|
||||
}
|
||||
|
||||
// drawHLine fills row `row` with `line` between optional left/right caps.
|
||||
// A zero rune means "no cap"; caps are placed at the very edges of `w`.
|
||||
func (w *LightWindow) drawHLine(row int, line, leftCap, rightCap rune, color ColorPair) {
|
||||
w.Move(row, 0)
|
||||
hw := runeWidth(line)
|
||||
width := w.width
|
||||
if leftCap != 0 {
|
||||
w.CPrint(color, string(leftCap))
|
||||
width -= runeWidth(leftCap)
|
||||
}
|
||||
if rightCap != 0 {
|
||||
width -= runeWidth(rightCap)
|
||||
}
|
||||
if width < 0 {
|
||||
width = 0
|
||||
}
|
||||
inner := width / hw
|
||||
rem := width - inner*hw
|
||||
w.CPrint(color, repeat(line, inner)+repeat(' ', rem))
|
||||
if rightCap != 0 {
|
||||
w.CPrint(color, string(rightCap))
|
||||
}
|
||||
}
|
||||
|
||||
func (w *LightWindow) DrawHSeparator(row int, windowType WindowType, useBottom bool) {
|
||||
if w.height == 0 {
|
||||
return
|
||||
}
|
||||
shape := w.border.shape
|
||||
if shape == BorderNone {
|
||||
return
|
||||
}
|
||||
color := BorderColor(windowType)
|
||||
line := w.border.top
|
||||
if useBottom {
|
||||
line = w.border.bottom
|
||||
}
|
||||
var leftCap, rightCap rune
|
||||
if shape.HasLeft() {
|
||||
leftCap = w.border.leftMid
|
||||
}
|
||||
if shape.HasRight() {
|
||||
rightCap = w.border.rightMid
|
||||
}
|
||||
w.drawHLine(row, line, leftCap, rightCap, color)
|
||||
}
|
||||
|
||||
func (w *LightWindow) PaintSectionFrame(topContent, bottomContent int, windowType WindowType, edge SectionEdge) {
|
||||
if w.height == 0 || w.border.shape == BorderNone {
|
||||
return
|
||||
}
|
||||
color := BorderColor(windowType)
|
||||
shape := w.border.shape
|
||||
hasLeft := shape.HasLeft()
|
||||
hasRight := shape.HasRight()
|
||||
rightW := runeWidth(w.border.right)
|
||||
// Content rows: overpaint left/right verticals + their 1-char margin.
|
||||
for row := topContent; row <= bottomContent; row++ {
|
||||
if hasLeft {
|
||||
w.Move(row, 0)
|
||||
w.CPrint(color, string(w.border.left)+" ")
|
||||
}
|
||||
if hasRight {
|
||||
w.Move(row, w.width-rightW-1)
|
||||
w.CPrint(color, " "+string(w.border.right))
|
||||
}
|
||||
}
|
||||
if edge == SectionEdgeTop && shape.HasTop() {
|
||||
var leftCap, rightCap rune
|
||||
if hasLeft {
|
||||
leftCap = w.border.topLeft
|
||||
}
|
||||
if hasRight {
|
||||
rightCap = w.border.topRight
|
||||
}
|
||||
w.drawHLine(0, w.border.top, leftCap, rightCap, color)
|
||||
}
|
||||
if edge == SectionEdgeBottom && shape.HasBottom() {
|
||||
var leftCap, rightCap rune
|
||||
if hasLeft {
|
||||
leftCap = w.border.bottomLeft
|
||||
}
|
||||
if hasRight {
|
||||
rightCap = w.border.bottomRight
|
||||
}
|
||||
w.drawHLine(w.height-1, w.border.bottom, leftCap, rightCap, color)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *LightWindow) drawBorder(onlyHorizontal bool) {
|
||||
if w.height == 0 {
|
||||
return
|
||||
}
|
||||
switch w.border.shape {
|
||||
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble:
|
||||
w.drawBorderAround(onlyHorizontal)
|
||||
case BorderHorizontal:
|
||||
w.drawBorderHorizontal(true, true)
|
||||
case BorderVertical:
|
||||
if onlyHorizontal {
|
||||
return
|
||||
}
|
||||
w.drawBorderVertical(true, true)
|
||||
case BorderTop:
|
||||
w.drawBorderHorizontal(true, false)
|
||||
case BorderBottom:
|
||||
w.drawBorderHorizontal(false, true)
|
||||
case BorderLeft:
|
||||
if onlyHorizontal {
|
||||
return
|
||||
}
|
||||
w.drawBorderVertical(true, false)
|
||||
case BorderRight:
|
||||
if onlyHorizontal {
|
||||
return
|
||||
}
|
||||
w.drawBorderVertical(false, true)
|
||||
shape := w.border.shape
|
||||
if shape == BorderNone {
|
||||
return
|
||||
}
|
||||
}
|
||||
color := BorderColor(w.windowType)
|
||||
hasLeft := shape.HasLeft()
|
||||
hasRight := shape.HasRight()
|
||||
|
||||
func (w *LightWindow) drawBorderHorizontal(top, bottom bool) {
|
||||
color := ColBorder
|
||||
switch w.windowType {
|
||||
case WindowList:
|
||||
color = ColListBorder
|
||||
case WindowInput:
|
||||
color = ColInputBorder
|
||||
case WindowHeader:
|
||||
color = ColHeaderBorder
|
||||
case WindowFooter:
|
||||
color = ColFooterBorder
|
||||
case WindowPreview:
|
||||
color = ColPreviewBorder
|
||||
}
|
||||
hw := runeWidth(w.border.top)
|
||||
if top {
|
||||
w.Move(0, 0)
|
||||
w.CPrint(color, repeat(w.border.top, w.width/hw))
|
||||
}
|
||||
|
||||
if bottom {
|
||||
w.Move(w.height-1, 0)
|
||||
w.CPrint(color, repeat(w.border.bottom, w.width/hw))
|
||||
}
|
||||
}
|
||||
|
||||
func (w *LightWindow) drawBorderVertical(left, right bool) {
|
||||
vw := runeWidth(w.border.left)
|
||||
color := ColBorder
|
||||
switch w.windowType {
|
||||
case WindowList:
|
||||
color = ColListBorder
|
||||
case WindowInput:
|
||||
color = ColInputBorder
|
||||
case WindowHeader:
|
||||
color = ColHeaderBorder
|
||||
case WindowFooter:
|
||||
color = ColFooterBorder
|
||||
case WindowPreview:
|
||||
color = ColPreviewBorder
|
||||
}
|
||||
for y := 0; y < w.height; y++ {
|
||||
if left {
|
||||
w.Move(y, 0)
|
||||
w.CPrint(color, string(w.border.left))
|
||||
w.CPrint(color, " ") // Margin
|
||||
if shape.HasTop() {
|
||||
var leftCap, rightCap rune
|
||||
if hasLeft {
|
||||
leftCap = w.border.topLeft
|
||||
}
|
||||
if right {
|
||||
w.Move(y, w.width-vw-1)
|
||||
w.CPrint(color, " ") // Margin
|
||||
w.CPrint(color, string(w.border.right))
|
||||
if hasRight {
|
||||
rightCap = w.border.topRight
|
||||
}
|
||||
w.drawHLine(0, w.border.top, leftCap, rightCap, color)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *LightWindow) drawBorderAround(onlyHorizontal bool) {
|
||||
w.Move(0, 0)
|
||||
color := ColBorder
|
||||
switch w.windowType {
|
||||
case WindowList:
|
||||
color = ColListBorder
|
||||
case WindowInput:
|
||||
color = ColInputBorder
|
||||
case WindowHeader:
|
||||
color = ColHeaderBorder
|
||||
case WindowFooter:
|
||||
color = ColFooterBorder
|
||||
case WindowPreview:
|
||||
color = ColPreviewBorder
|
||||
}
|
||||
hw := runeWidth(w.border.top)
|
||||
tcw := runeWidth(w.border.topLeft) + runeWidth(w.border.topRight)
|
||||
bcw := runeWidth(w.border.bottomLeft) + runeWidth(w.border.bottomRight)
|
||||
rem := (w.width - tcw) % hw
|
||||
w.CPrint(color, string(w.border.topLeft)+repeat(w.border.top, (w.width-tcw)/hw)+repeat(' ', rem)+string(w.border.topRight))
|
||||
if !onlyHorizontal {
|
||||
if !onlyHorizontal && (hasLeft || hasRight) {
|
||||
vw := runeWidth(w.border.left)
|
||||
for y := 1; y < w.height-1; y++ {
|
||||
w.Move(y, 0)
|
||||
w.CPrint(color, string(w.border.left))
|
||||
w.CPrint(color, " ") // Margin
|
||||
|
||||
w.Move(y, w.width-vw-1)
|
||||
w.CPrint(color, " ") // Margin
|
||||
w.CPrint(color, string(w.border.right))
|
||||
for y := 0; y < w.height; y++ {
|
||||
// Corner rows are already painted by drawHLine above / below.
|
||||
if (y == 0 && shape.HasTop()) || (y == w.height-1 && shape.HasBottom()) {
|
||||
continue
|
||||
}
|
||||
if hasLeft {
|
||||
w.Move(y, 0)
|
||||
w.CPrint(color, string(w.border.left)+" ")
|
||||
}
|
||||
if hasRight {
|
||||
w.Move(y, w.width-vw-1)
|
||||
w.CPrint(color, " "+string(w.border.right))
|
||||
}
|
||||
}
|
||||
}
|
||||
w.Move(w.height-1, 0)
|
||||
rem = (w.width - bcw) % hw
|
||||
w.CPrint(color, string(w.border.bottomLeft)+repeat(w.border.bottom, (w.width-bcw)/hw)+repeat(' ', rem)+string(w.border.bottomRight))
|
||||
if shape.HasBottom() {
|
||||
var leftCap, rightCap rune
|
||||
if hasLeft {
|
||||
leftCap = w.border.bottomLeft
|
||||
}
|
||||
if hasRight {
|
||||
rightCap = w.border.bottomRight
|
||||
}
|
||||
w.drawHLine(w.height-1, w.border.bottom, leftCap, rightCap, color)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *LightWindow) csi(code string) string {
|
||||
|
||||
+135
-54
@@ -1017,6 +1017,115 @@ func (w *TcellWindow) DrawHBorder() {
|
||||
w.drawBorder(true)
|
||||
}
|
||||
|
||||
// borderStyleFor returns the tcell.Style used to draw borders for `wt`, honoring
|
||||
// whether the window is rendering with colors.
|
||||
func (w *TcellWindow) borderStyleFor(wt WindowType) tcell.Style {
|
||||
if !w.color {
|
||||
return w.normal.style()
|
||||
}
|
||||
return BorderColor(wt).style()
|
||||
}
|
||||
|
||||
// drawHLine fills row `y` with `line` between optional left/right caps.
|
||||
// A zero rune means "no cap"; caps are placed at the very edges of `w`.
|
||||
// tcell has an issue displaying two overlapping wide runes, so the line
|
||||
// stops before the cap position rather than overpainting.
|
||||
func (w *TcellWindow) drawHLine(y int, line, leftCap, rightCap rune, style tcell.Style) {
|
||||
left := w.left
|
||||
right := left + w.width
|
||||
hw := runeWidth(line)
|
||||
lw := 0
|
||||
rw := 0
|
||||
if leftCap != 0 {
|
||||
lw = runeWidth(leftCap)
|
||||
}
|
||||
if rightCap != 0 {
|
||||
rw = runeWidth(rightCap)
|
||||
}
|
||||
for x := left + lw; x <= right-rw-hw; x += hw {
|
||||
_screen.SetContent(x, y, line, nil, style)
|
||||
}
|
||||
if leftCap != 0 {
|
||||
_screen.SetContent(left, y, leftCap, nil, style)
|
||||
}
|
||||
if rightCap != 0 {
|
||||
_screen.SetContent(right-rw, y, rightCap, nil, style)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *TcellWindow) DrawHSeparator(row int, windowType WindowType, useBottom bool) {
|
||||
if w.height == 0 {
|
||||
return
|
||||
}
|
||||
shape := w.borderStyle.shape
|
||||
if shape == BorderNone {
|
||||
return
|
||||
}
|
||||
style := w.borderStyleFor(windowType)
|
||||
line := w.borderStyle.top
|
||||
if useBottom {
|
||||
line = w.borderStyle.bottom
|
||||
}
|
||||
var leftCap, rightCap rune
|
||||
if shape.HasLeft() {
|
||||
leftCap = w.borderStyle.leftMid
|
||||
}
|
||||
if shape.HasRight() {
|
||||
rightCap = w.borderStyle.rightMid
|
||||
}
|
||||
w.drawHLine(w.top+row, line, leftCap, rightCap, style)
|
||||
}
|
||||
|
||||
func (w *TcellWindow) PaintSectionFrame(topContent, bottomContent int, windowType WindowType, edge SectionEdge) {
|
||||
if w.height == 0 {
|
||||
return
|
||||
}
|
||||
shape := w.borderStyle.shape
|
||||
if shape == BorderNone {
|
||||
return
|
||||
}
|
||||
style := w.borderStyleFor(windowType)
|
||||
left := w.left
|
||||
right := left + w.width
|
||||
hasLeft := shape.HasLeft()
|
||||
hasRight := shape.HasRight()
|
||||
leftW := runeWidth(w.borderStyle.left)
|
||||
rightW := runeWidth(w.borderStyle.right)
|
||||
// Content rows: overpaint the left and right verticals (+ their 1-char margin) in
|
||||
// the section's color. Inner margin stays at whatever bg the sub-window set.
|
||||
for row := topContent; row <= bottomContent; row++ {
|
||||
y := w.top + row
|
||||
if hasLeft {
|
||||
_screen.SetContent(left, y, w.borderStyle.left, nil, style)
|
||||
_screen.SetContent(left+leftW, y, ' ', nil, style)
|
||||
}
|
||||
if hasRight {
|
||||
_screen.SetContent(right-rightW-1, y, ' ', nil, style)
|
||||
_screen.SetContent(right-rightW, y, w.borderStyle.right, nil, style)
|
||||
}
|
||||
}
|
||||
if edge == SectionEdgeTop && shape.HasTop() {
|
||||
var leftCap, rightCap rune
|
||||
if hasLeft {
|
||||
leftCap = w.borderStyle.topLeft
|
||||
}
|
||||
if hasRight {
|
||||
rightCap = w.borderStyle.topRight
|
||||
}
|
||||
w.drawHLine(w.top, w.borderStyle.top, leftCap, rightCap, style)
|
||||
}
|
||||
if edge == SectionEdgeBottom && shape.HasBottom() {
|
||||
var leftCap, rightCap rune
|
||||
if hasLeft {
|
||||
leftCap = w.borderStyle.bottomLeft
|
||||
}
|
||||
if hasRight {
|
||||
rightCap = w.borderStyle.bottomRight
|
||||
}
|
||||
w.drawHLine(w.top+w.height-1, w.borderStyle.bottom, leftCap, rightCap, style)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *TcellWindow) drawBorder(onlyHorizontal bool) {
|
||||
if w.height == 0 {
|
||||
return
|
||||
@@ -1031,72 +1140,44 @@ func (w *TcellWindow) drawBorder(onlyHorizontal bool) {
|
||||
top := w.top
|
||||
bot := top + w.height
|
||||
|
||||
var style tcell.Style
|
||||
if w.color {
|
||||
switch w.windowType {
|
||||
case WindowBase:
|
||||
style = ColBorder.style()
|
||||
case WindowList:
|
||||
style = ColListBorder.style()
|
||||
case WindowHeader:
|
||||
style = ColHeaderBorder.style()
|
||||
case WindowFooter:
|
||||
style = ColFooterBorder.style()
|
||||
case WindowInput:
|
||||
style = ColInputBorder.style()
|
||||
case WindowPreview:
|
||||
style = ColPreviewBorder.style()
|
||||
}
|
||||
} else {
|
||||
style = w.normal.style()
|
||||
}
|
||||
style := w.borderStyleFor(w.windowType)
|
||||
|
||||
hw := runeWidth(w.borderStyle.top)
|
||||
switch shape {
|
||||
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble, BorderHorizontal, BorderTop:
|
||||
max := right - 2*hw
|
||||
if shape == BorderHorizontal || shape == BorderTop {
|
||||
max = right - hw
|
||||
hasLeft := shape.HasLeft()
|
||||
hasRight := shape.HasRight()
|
||||
|
||||
if shape.HasTop() {
|
||||
var leftCap, rightCap rune
|
||||
if hasLeft {
|
||||
leftCap = w.borderStyle.topLeft
|
||||
}
|
||||
// tcell has an issue displaying two overlapping wide runes
|
||||
// e.g. SetContent( HH )
|
||||
// SetContent( TR )
|
||||
// ==================
|
||||
// ( HH ) => TR is ignored
|
||||
for x := left; x <= max; x += hw {
|
||||
_screen.SetContent(x, top, w.borderStyle.top, nil, style)
|
||||
if hasRight {
|
||||
rightCap = w.borderStyle.topRight
|
||||
}
|
||||
w.drawHLine(top, w.borderStyle.top, leftCap, rightCap, style)
|
||||
}
|
||||
switch shape {
|
||||
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble, BorderHorizontal, BorderBottom:
|
||||
max := right - 2*hw
|
||||
if shape == BorderHorizontal || shape == BorderBottom {
|
||||
max = right - hw
|
||||
if shape.HasBottom() {
|
||||
var leftCap, rightCap rune
|
||||
if hasLeft {
|
||||
leftCap = w.borderStyle.bottomLeft
|
||||
}
|
||||
for x := left; x <= max; x += hw {
|
||||
_screen.SetContent(x, bot-1, w.borderStyle.bottom, nil, style)
|
||||
if hasRight {
|
||||
rightCap = w.borderStyle.bottomRight
|
||||
}
|
||||
w.drawHLine(bot-1, w.borderStyle.bottom, leftCap, rightCap, style)
|
||||
}
|
||||
if !onlyHorizontal {
|
||||
switch shape {
|
||||
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble, BorderVertical, BorderLeft:
|
||||
for y := top; y < bot; y++ {
|
||||
vw := runeWidth(w.borderStyle.right)
|
||||
for y := top; y < bot; y++ {
|
||||
// Corner rows are already painted by drawHLine above / below.
|
||||
if (y == top && shape.HasTop()) || (y == bot-1 && shape.HasBottom()) {
|
||||
continue
|
||||
}
|
||||
if hasLeft {
|
||||
_screen.SetContent(left, y, w.borderStyle.left, nil, style)
|
||||
}
|
||||
}
|
||||
switch shape {
|
||||
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble, BorderVertical, BorderRight:
|
||||
vw := runeWidth(w.borderStyle.right)
|
||||
for y := top; y < bot; y++ {
|
||||
if hasRight {
|
||||
_screen.SetContent(right-vw, y, w.borderStyle.right, nil, style)
|
||||
}
|
||||
}
|
||||
}
|
||||
switch shape {
|
||||
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble:
|
||||
_screen.SetContent(left, top, w.borderStyle.topLeft, nil, style)
|
||||
_screen.SetContent(right-runeWidth(w.borderStyle.topRight), top, w.borderStyle.topRight, nil, style)
|
||||
_screen.SetContent(left, bot-1, w.borderStyle.bottomLeft, nil, style)
|
||||
_screen.SetContent(right-runeWidth(w.borderStyle.bottomRight), bot-1, w.borderStyle.bottomRight, nil, style)
|
||||
}
|
||||
}
|
||||
|
||||
+179
-73
@@ -4,6 +4,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/junegunn/fzf/src/util"
|
||||
"github.com/rivo/uniseg"
|
||||
@@ -196,11 +197,6 @@ const (
|
||||
CtrlAltShiftPageUp
|
||||
CtrlAltShiftPageDown
|
||||
|
||||
Invalid
|
||||
Fatal
|
||||
BracketedPasteBegin
|
||||
BracketedPasteEnd
|
||||
|
||||
Mouse
|
||||
DoubleClick
|
||||
LeftClick
|
||||
@@ -214,7 +210,15 @@ const (
|
||||
PreviewScrollUp
|
||||
PreviewScrollDown
|
||||
|
||||
// Events
|
||||
// Synthetic / non-user events. Everything from Invalid onward is
|
||||
// either internally generated or a state-change notification, not
|
||||
// direct user input. Use `>= Invalid` to gate activity tracking.
|
||||
// BracketedPasteBegin/End sit here too: they enclose user input
|
||||
// (which arrives as Rune events) and should not appear in FZF_KEY.
|
||||
Invalid
|
||||
Fatal
|
||||
BracketedPasteBegin
|
||||
BracketedPasteEnd
|
||||
Resize
|
||||
Change
|
||||
BackwardEOF
|
||||
@@ -229,6 +233,7 @@ const (
|
||||
ClickHeader
|
||||
ClickFooter
|
||||
Multi
|
||||
Every
|
||||
)
|
||||
|
||||
func (t EventType) AsEvent() Event {
|
||||
@@ -248,6 +253,12 @@ func (e Event) Comparable() Event {
|
||||
return Event{e.Type, e.Char, nil}
|
||||
}
|
||||
|
||||
// Printable returns true if the event is a printable character that can be
|
||||
// inserted into the query (e.g. via the 'put' action).
|
||||
func (e Event) Printable() bool {
|
||||
return e.Type == Rune && unicode.IsGraphic(e.Char)
|
||||
}
|
||||
|
||||
func (e Event) KeyName() string {
|
||||
if me := e.MouseEvent; me != nil {
|
||||
return me.Name()
|
||||
@@ -506,7 +517,7 @@ type ColorTheme struct {
|
||||
CurrentMatch ColorAttr
|
||||
Spinner ColorAttr
|
||||
Info ColorAttr
|
||||
Cursor ColorAttr
|
||||
Pointer ColorAttr
|
||||
Marker ColorAttr
|
||||
Header ColorAttr
|
||||
HeaderBg ColorAttr
|
||||
@@ -595,11 +606,13 @@ const (
|
||||
BorderBottom
|
||||
BorderLeft
|
||||
BorderRight
|
||||
BorderInline
|
||||
BorderDashed
|
||||
)
|
||||
|
||||
func (s BorderShape) HasLeft() bool {
|
||||
switch s {
|
||||
case BorderNone, BorderPhantom, BorderLine, BorderRight, BorderTop, BorderBottom, BorderHorizontal: // No Left
|
||||
case BorderNone, BorderPhantom, BorderLine, BorderInline, BorderRight, BorderTop, BorderBottom, BorderHorizontal: // No Left
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@@ -607,7 +620,7 @@ func (s BorderShape) HasLeft() bool {
|
||||
|
||||
func (s BorderShape) HasRight() bool {
|
||||
switch s {
|
||||
case BorderNone, BorderPhantom, BorderLine, BorderLeft, BorderTop, BorderBottom, BorderHorizontal: // No right
|
||||
case BorderNone, BorderPhantom, BorderLine, BorderInline, BorderLeft, BorderTop, BorderBottom, BorderHorizontal: // No right
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@@ -615,7 +628,7 @@ func (s BorderShape) HasRight() bool {
|
||||
|
||||
func (s BorderShape) HasTop() bool {
|
||||
switch s {
|
||||
case BorderNone, BorderPhantom, BorderLine, BorderLeft, BorderRight, BorderBottom, BorderVertical: // No top
|
||||
case BorderNone, BorderPhantom, BorderLine, BorderInline, BorderLeft, BorderRight, BorderBottom, BorderVertical: // No top
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@@ -623,7 +636,7 @@ func (s BorderShape) HasTop() bool {
|
||||
|
||||
func (s BorderShape) HasBottom() bool {
|
||||
switch s {
|
||||
case BorderNone, BorderPhantom, BorderLine, BorderLeft, BorderRight, BorderTop, BorderVertical: // No bottom
|
||||
case BorderNone, BorderPhantom, BorderLine, BorderInline, BorderLeft, BorderRight, BorderTop, BorderVertical: // No bottom
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@@ -643,6 +656,8 @@ type BorderStyle struct {
|
||||
topRight rune
|
||||
bottomLeft rune
|
||||
bottomRight rune
|
||||
leftMid rune
|
||||
rightMid rune
|
||||
}
|
||||
|
||||
type BorderCharacter int
|
||||
@@ -658,7 +673,9 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
|
||||
topLeft: ' ',
|
||||
topRight: ' ',
|
||||
bottomLeft: ' ',
|
||||
bottomRight: ' '}
|
||||
bottomRight: ' ',
|
||||
leftMid: ' ',
|
||||
rightMid: ' '}
|
||||
}
|
||||
if !unicode {
|
||||
return BorderStyle{
|
||||
@@ -671,6 +688,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
|
||||
topRight: '+',
|
||||
bottomLeft: '+',
|
||||
bottomRight: '+',
|
||||
leftMid: '+',
|
||||
rightMid: '+',
|
||||
}
|
||||
}
|
||||
switch shape {
|
||||
@@ -685,6 +704,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
|
||||
topRight: '┐',
|
||||
bottomLeft: '└',
|
||||
bottomRight: '┘',
|
||||
leftMid: '├',
|
||||
rightMid: '┤',
|
||||
}
|
||||
case BorderBold:
|
||||
return BorderStyle{
|
||||
@@ -697,6 +718,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
|
||||
topRight: '┓',
|
||||
bottomLeft: '┗',
|
||||
bottomRight: '┛',
|
||||
leftMid: '┣',
|
||||
rightMid: '┫',
|
||||
}
|
||||
case BorderBlock:
|
||||
// ▛▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▜
|
||||
@@ -712,6 +735,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
|
||||
topRight: '▜',
|
||||
bottomLeft: '▙',
|
||||
bottomRight: '▟',
|
||||
leftMid: '▌',
|
||||
rightMid: '▐',
|
||||
}
|
||||
|
||||
case BorderThinBlock:
|
||||
@@ -728,6 +753,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
|
||||
topRight: '🭾',
|
||||
bottomLeft: '🭼',
|
||||
bottomRight: '🭿',
|
||||
leftMid: '▏',
|
||||
rightMid: '▕',
|
||||
}
|
||||
|
||||
case BorderDouble:
|
||||
@@ -741,6 +768,25 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
|
||||
topRight: '╗',
|
||||
bottomLeft: '╚',
|
||||
bottomRight: '╝',
|
||||
leftMid: '╠',
|
||||
rightMid: '╣',
|
||||
}
|
||||
case BorderDashed:
|
||||
// Terminal cells are taller than wide (~2:1), so horizontals can use a
|
||||
// sparse stub per cell while verticals need more dashes per cell to look
|
||||
// evenly dashed. Rounded corners and sharp T-junction mids.
|
||||
return BorderStyle{
|
||||
shape: shape,
|
||||
top: '╶',
|
||||
bottom: '╶',
|
||||
left: '┆',
|
||||
right: '┆',
|
||||
topLeft: '╭',
|
||||
topRight: '╮',
|
||||
bottomLeft: '╰',
|
||||
bottomRight: '╯',
|
||||
leftMid: '├',
|
||||
rightMid: '┤',
|
||||
}
|
||||
}
|
||||
return BorderStyle{
|
||||
@@ -753,6 +799,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
|
||||
topRight: '╮',
|
||||
bottomLeft: '╰',
|
||||
bottomRight: '╯',
|
||||
leftMid: '├',
|
||||
rightMid: '┤',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -774,6 +822,35 @@ const (
|
||||
WindowFooter
|
||||
)
|
||||
|
||||
// BorderColor returns the ColorPair used to draw borders for the given WindowType.
|
||||
func BorderColor(wt WindowType) ColorPair {
|
||||
switch wt {
|
||||
case WindowList:
|
||||
return ColListBorder
|
||||
case WindowInput:
|
||||
return ColInputBorder
|
||||
case WindowHeader:
|
||||
return ColHeaderBorder
|
||||
case WindowFooter:
|
||||
return ColFooterBorder
|
||||
case WindowPreview:
|
||||
return ColPreviewBorder
|
||||
}
|
||||
return ColBorder
|
||||
}
|
||||
|
||||
// SectionEdge selects which outer edge of the frame an inline section
|
||||
// should claim when PaintSectionFrame overpaints its adjacent border.
|
||||
// SectionEdgeNone paints only the inner verticals (for sections that
|
||||
// don't touch the outer top or bottom).
|
||||
type SectionEdge int
|
||||
|
||||
const (
|
||||
SectionEdgeNone SectionEdge = iota
|
||||
SectionEdgeTop
|
||||
SectionEdgeBottom
|
||||
)
|
||||
|
||||
type Renderer interface {
|
||||
DefaultTheme() *ColorTheme
|
||||
Init() error
|
||||
@@ -811,6 +888,19 @@ type Window interface {
|
||||
|
||||
DrawBorder()
|
||||
DrawHBorder()
|
||||
// DrawHSeparator draws an inline horizontal separator at `row` (relative to the
|
||||
// window's top) using the color for `windowType`. The separator is conceptually
|
||||
// the section's inner edge (e.g. the bottom border of an inline header), so the
|
||||
// whole row including junctions carries the section's fg + bg. When useBottom is
|
||||
// true the `bottom` horizontal char is used instead of `top`; for thinblock/block
|
||||
// styles this keeps the thin line bonded to the list content on the opposite side.
|
||||
DrawHSeparator(row int, windowType WindowType, useBottom bool)
|
||||
// PaintSectionFrame overpaints the border cells around the rows [topContent,
|
||||
// bottomContent] (inclusive, relative to the window's top) with the color for
|
||||
// `windowType`. When edge is SectionEdgeTop / SectionEdgeBottom, the
|
||||
// corresponding outer horizontal (+ corners) is also painted, letting the
|
||||
// inline section claim that edge of the outer frame.
|
||||
PaintSectionFrame(topContent, bottomContent int, windowType WindowType, edge SectionEdge)
|
||||
Refresh()
|
||||
FinishFill()
|
||||
|
||||
@@ -869,18 +959,18 @@ var (
|
||||
ColDisabled ColorPair
|
||||
ColGhost ColorPair
|
||||
ColMatch ColorPair
|
||||
ColCursor ColorPair
|
||||
ColCursorEmpty ColorPair
|
||||
ColCursorEmptyChar ColorPair
|
||||
ColAltCursorEmpty ColorPair
|
||||
ColAltCursorEmptyChar ColorPair
|
||||
ColPointer ColorPair
|
||||
ColPointerEmpty ColorPair
|
||||
ColPointerEmptyChar ColorPair
|
||||
ColAltPointerEmpty ColorPair
|
||||
ColAltPointerEmptyChar ColorPair
|
||||
ColMarker ColorPair
|
||||
ColSelected ColorPair
|
||||
ColSelectedMatch ColorPair
|
||||
ColCurrent ColorPair
|
||||
ColCurrentMatch ColorPair
|
||||
ColCurrentCursor ColorPair
|
||||
ColCurrentCursorEmpty ColorPair
|
||||
ColCurrentPointer ColorPair
|
||||
ColCurrentPointerEmpty ColorPair
|
||||
ColCurrentMarker ColorPair
|
||||
ColCurrentSelectedEmpty ColorPair
|
||||
ColSpinner ColorPair
|
||||
@@ -912,51 +1002,56 @@ func init() {
|
||||
undefined := ColorAttr{colUndefined, AttrUndefined}
|
||||
|
||||
NoColorTheme = &ColorTheme{
|
||||
Colored: false,
|
||||
Input: defaultColor,
|
||||
Fg: defaultColor,
|
||||
Bg: defaultColor,
|
||||
ListFg: defaultColor,
|
||||
ListBg: defaultColor,
|
||||
Colored: false,
|
||||
// Root colors. Everything else is left undefined so that overriding a
|
||||
// root (e.g. --color bw,bg:blue) propagates to the derived colors,
|
||||
// just like in the colored base themes.
|
||||
Input: defaultColor,
|
||||
Fg: defaultColor,
|
||||
Bg: defaultColor,
|
||||
DarkBg: defaultColor,
|
||||
Prompt: defaultColor,
|
||||
Match: defaultColor,
|
||||
Spinner: defaultColor,
|
||||
Info: defaultColor,
|
||||
Pointer: defaultColor,
|
||||
Marker: defaultColor,
|
||||
Header: defaultColor,
|
||||
Footer: defaultColor,
|
||||
BorderLabel: defaultColor,
|
||||
// Derived colors. Left undefined so they inherit from a root.
|
||||
ListFg: undefined,
|
||||
ListBg: undefined,
|
||||
AltBg: undefined,
|
||||
SelectedFg: defaultColor,
|
||||
SelectedBg: defaultColor,
|
||||
SelectedMatch: defaultColor,
|
||||
DarkBg: defaultColor,
|
||||
Prompt: defaultColor,
|
||||
Match: defaultColor,
|
||||
SelectedFg: undefined,
|
||||
SelectedBg: undefined,
|
||||
SelectedMatch: undefined,
|
||||
Current: undefined,
|
||||
CurrentMatch: undefined,
|
||||
Spinner: defaultColor,
|
||||
Info: defaultColor,
|
||||
Cursor: defaultColor,
|
||||
Marker: defaultColor,
|
||||
Header: defaultColor,
|
||||
Border: undefined,
|
||||
BorderLabel: defaultColor,
|
||||
Ghost: undefined,
|
||||
Disabled: defaultColor,
|
||||
PreviewFg: defaultColor,
|
||||
PreviewBg: defaultColor,
|
||||
Disabled: undefined,
|
||||
PreviewFg: undefined,
|
||||
PreviewBg: undefined,
|
||||
Gutter: undefined,
|
||||
AltGutter: undefined,
|
||||
PreviewBorder: defaultColor,
|
||||
PreviewScrollbar: defaultColor,
|
||||
PreviewLabel: defaultColor,
|
||||
ListLabel: defaultColor,
|
||||
ListBorder: defaultColor,
|
||||
Separator: defaultColor,
|
||||
Scrollbar: defaultColor,
|
||||
InputBg: defaultColor,
|
||||
InputBorder: defaultColor,
|
||||
InputLabel: defaultColor,
|
||||
HeaderBg: defaultColor,
|
||||
HeaderBorder: defaultColor,
|
||||
HeaderLabel: defaultColor,
|
||||
FooterBg: defaultColor,
|
||||
FooterBorder: defaultColor,
|
||||
FooterLabel: defaultColor,
|
||||
GapLine: defaultColor,
|
||||
PreviewBorder: undefined,
|
||||
PreviewScrollbar: undefined,
|
||||
PreviewLabel: undefined,
|
||||
ListLabel: undefined,
|
||||
ListBorder: undefined,
|
||||
Separator: undefined,
|
||||
Scrollbar: undefined,
|
||||
InputBg: undefined,
|
||||
InputBorder: undefined,
|
||||
InputLabel: undefined,
|
||||
HeaderBg: undefined,
|
||||
HeaderBorder: undefined,
|
||||
HeaderLabel: undefined,
|
||||
FooterBg: undefined,
|
||||
FooterBorder: undefined,
|
||||
FooterLabel: undefined,
|
||||
GapLine: undefined,
|
||||
Nth: undefined,
|
||||
Nomatch: undefined,
|
||||
}
|
||||
@@ -979,7 +1074,7 @@ func init() {
|
||||
CurrentMatch: undefined,
|
||||
Spinner: undefined,
|
||||
Info: undefined,
|
||||
Cursor: undefined,
|
||||
Pointer: undefined,
|
||||
Marker: undefined,
|
||||
Header: undefined,
|
||||
Footer: undefined,
|
||||
@@ -1030,7 +1125,7 @@ func init() {
|
||||
CurrentMatch: ColorAttr{colBrightGreen, AttrUndefined},
|
||||
Spinner: ColorAttr{colGreen, AttrUndefined},
|
||||
Info: ColorAttr{colYellow, AttrUndefined},
|
||||
Cursor: ColorAttr{colRed, AttrUndefined},
|
||||
Pointer: ColorAttr{colRed, AttrUndefined},
|
||||
Marker: ColorAttr{colMagenta, AttrUndefined},
|
||||
Header: ColorAttr{colCyan, AttrUndefined},
|
||||
Footer: ColorAttr{colCyan, AttrUndefined},
|
||||
@@ -1081,7 +1176,7 @@ func init() {
|
||||
CurrentMatch: ColorAttr{151, AttrUndefined},
|
||||
Spinner: ColorAttr{148, AttrUndefined},
|
||||
Info: ColorAttr{144, AttrUndefined},
|
||||
Cursor: ColorAttr{161, AttrUndefined},
|
||||
Pointer: ColorAttr{161, AttrUndefined},
|
||||
Marker: ColorAttr{168, AttrUndefined},
|
||||
Header: ColorAttr{109, AttrUndefined},
|
||||
Footer: ColorAttr{109, AttrUndefined},
|
||||
@@ -1132,7 +1227,7 @@ func init() {
|
||||
CurrentMatch: ColorAttr{23, AttrUndefined},
|
||||
Spinner: ColorAttr{65, AttrUndefined},
|
||||
Info: ColorAttr{101, AttrUndefined},
|
||||
Cursor: ColorAttr{161, AttrUndefined},
|
||||
Pointer: ColorAttr{161, AttrUndefined},
|
||||
Marker: ColorAttr{168, AttrUndefined},
|
||||
Header: ColorAttr{31, AttrUndefined},
|
||||
Footer: ColorAttr{31, AttrUndefined},
|
||||
@@ -1166,7 +1261,7 @@ func init() {
|
||||
}
|
||||
}
|
||||
|
||||
func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlack bool, hasInputWindow bool, hasHeaderWindow bool) {
|
||||
func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlack bool, hasInputWindow bool, hasHeaderWindow bool, headerInline bool, footerInline bool) {
|
||||
if forceBlack {
|
||||
theme.Bg = ColorAttr{colBlack, AttrUndefined}
|
||||
}
|
||||
@@ -1183,7 +1278,7 @@ func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlac
|
||||
theme.CurrentMatch = boldify(theme.CurrentMatch)
|
||||
theme.Prompt = boldify(theme.Prompt)
|
||||
theme.Input = boldify(theme.Input)
|
||||
theme.Cursor = boldify(theme.Cursor)
|
||||
theme.Pointer = boldify(theme.Pointer)
|
||||
theme.Spinner = boldify(theme.Spinner)
|
||||
}
|
||||
|
||||
@@ -1227,7 +1322,7 @@ func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlac
|
||||
theme.CurrentMatch = o(baseTheme.CurrentMatch, currentMatch)
|
||||
theme.Spinner = o(baseTheme.Spinner, theme.Spinner)
|
||||
theme.Info = o(baseTheme.Info, theme.Info)
|
||||
theme.Cursor = o(baseTheme.Cursor, theme.Cursor)
|
||||
theme.Pointer = o(baseTheme.Pointer, theme.Pointer)
|
||||
theme.Marker = o(baseTheme.Marker, theme.Marker)
|
||||
theme.Header = o(baseTheme.Header, theme.Header)
|
||||
theme.Footer = o(baseTheme.Footer, theme.Footer)
|
||||
@@ -1300,11 +1395,22 @@ func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlac
|
||||
} else {
|
||||
theme.HeaderBg = o(theme.Bg, theme.ListBg)
|
||||
}
|
||||
theme.HeaderBorder = o(theme.Border, theme.HeaderBorder)
|
||||
// Inline header/footer borders sit inside the list frame, so default their color
|
||||
// to the list-border color when the user has not explicitly set it. The inline
|
||||
// separator then matches the surrounding frame.
|
||||
headerBorderFallback := theme.Border
|
||||
if headerInline {
|
||||
headerBorderFallback = theme.ListBorder
|
||||
}
|
||||
theme.HeaderBorder = o(headerBorderFallback, theme.HeaderBorder)
|
||||
theme.HeaderLabel = o(theme.BorderLabel, theme.HeaderLabel)
|
||||
|
||||
theme.FooterBg = o(theme.Bg, theme.FooterBg)
|
||||
theme.FooterBorder = o(theme.Border, theme.FooterBorder)
|
||||
footerBorderFallback := theme.Border
|
||||
if footerInline {
|
||||
footerBorderFallback = theme.ListBorder
|
||||
}
|
||||
theme.FooterBorder = o(footerBorderFallback, theme.FooterBorder)
|
||||
theme.FooterLabel = o(theme.BorderLabel, theme.FooterLabel)
|
||||
|
||||
if theme.Nomatch.IsUndefined() {
|
||||
@@ -1332,11 +1438,11 @@ func initPalette(theme *ColorTheme) {
|
||||
ColDisabled = pair(theme.Disabled, theme.InputBg)
|
||||
ColMatch = pair(theme.Match, theme.ListBg)
|
||||
ColSelectedMatch = pair(theme.SelectedMatch, theme.SelectedBg)
|
||||
ColCursor = pair(theme.Cursor, theme.Gutter)
|
||||
ColCursorEmpty = pair(blank, theme.Gutter)
|
||||
ColCursorEmptyChar = pair(theme.Gutter, theme.ListBg)
|
||||
ColAltCursorEmpty = pair(blank, theme.AltGutter)
|
||||
ColAltCursorEmptyChar = pair(theme.AltGutter, theme.ListBg)
|
||||
ColPointer = pair(theme.Pointer, theme.Gutter)
|
||||
ColPointerEmpty = pair(blank, theme.Gutter)
|
||||
ColPointerEmptyChar = pair(theme.Gutter, theme.ListBg)
|
||||
ColAltPointerEmpty = pair(blank, theme.AltGutter)
|
||||
ColAltPointerEmptyChar = pair(theme.AltGutter, theme.ListBg)
|
||||
if theme.SelectedBg.Color != theme.ListBg.Color {
|
||||
ColMarker = pair(theme.Marker, theme.SelectedBg)
|
||||
} else {
|
||||
@@ -1344,8 +1450,8 @@ func initPalette(theme *ColorTheme) {
|
||||
}
|
||||
ColCurrent = pair(theme.Current, theme.DarkBg)
|
||||
ColCurrentMatch = pair(theme.CurrentMatch, theme.DarkBg)
|
||||
ColCurrentCursor = pair(theme.Cursor, theme.DarkBg)
|
||||
ColCurrentCursorEmpty = pair(blank, theme.DarkBg)
|
||||
ColCurrentPointer = pair(theme.Pointer, theme.DarkBg)
|
||||
ColCurrentPointerEmpty = pair(blank, theme.DarkBg)
|
||||
ColCurrentMarker = pair(theme.Marker, theme.DarkBg)
|
||||
ColCurrentSelectedEmpty = pair(blank, theme.DarkBg)
|
||||
ColSpinner = pair(theme.Spinner, theme.InputBg)
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ func TestWrapLine(t *testing.T) {
|
||||
t.Errorf("Basic wrap: %v", lines)
|
||||
}
|
||||
|
||||
// Exact fit — no wrapping needed
|
||||
// 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)
|
||||
|
||||
+148
-7
@@ -78,6 +78,38 @@ class Shell
|
||||
"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
|
||||
|
||||
@@ -85,12 +117,31 @@ class Tmux
|
||||
attr_reader :win
|
||||
|
||||
def initialize(shell = :bash)
|
||||
@shell = shell
|
||||
@win = go(%W[new-window -d -P -F #I #{Shell.send(shell)}]).first
|
||||
go(%W[set-window-option -t #{@win} pane-base-index 0])
|
||||
return unless shell == :fish
|
||||
if shell == :fish
|
||||
send_keys 'function fish_prompt; end; clear', :Enter
|
||||
self.until(&:empty?)
|
||||
elsif shell == :nushell
|
||||
# Clear history from previous tests to avoid contamination
|
||||
FileUtils.rm_f('/tmp/fzf-nushell-xdg/nushell/history.txt')
|
||||
# Wait for nushell to be ready by polling with a marker command.
|
||||
# We use 'print "fzf-ready"' and check for a line that is exactly
|
||||
# 'fzf-ready' (not the command echo which includes 'print').
|
||||
retries = 0
|
||||
begin
|
||||
send_keys 'print "fzf-ready"', :Enter
|
||||
self.until { |lines| lines.any? { |l| l.strip == 'fzf-ready' } }
|
||||
rescue Minitest::Assertion
|
||||
retries += 1
|
||||
raise if retries > 5
|
||||
|
||||
send_keys 'function fish_prompt; end; clear', :Enter
|
||||
self.until(&:empty?)
|
||||
retry
|
||||
end
|
||||
send_keys 'clear', :Enter
|
||||
self.until(&:empty?)
|
||||
end
|
||||
end
|
||||
|
||||
def kill
|
||||
@@ -105,6 +156,23 @@ class Tmux
|
||||
go(%W[send-keys -t #{win}] + args.map(&:to_s))
|
||||
end
|
||||
|
||||
# Simulate a mouse click at the given 1-based column and row using the SGR mouse protocol
|
||||
# (xterm mouse mode 1006, which fzf enables). The escape sequence is injected as literal
|
||||
# keystrokes via tmux, and fzf parses it like a real terminal mouse event.
|
||||
#
|
||||
# tmux's own mouse handling intercepts these sequences when `set -g mouse on`, so we toggle
|
||||
# mouse off for the duration of the click and restore the previous state afterwards.
|
||||
def click(col, row, button: 0)
|
||||
prev = go(%w[show-options -gv mouse]).first
|
||||
go(%w[set-option -g mouse off])
|
||||
begin
|
||||
seq = "\e[<#{button};#{col};#{row}M\e[<#{button};#{col};#{row}m"
|
||||
go(%W[send-keys -t #{win} -l #{seq}])
|
||||
ensure
|
||||
go(%W[set-option -g mouse #{prev}]) if prev && !prev.empty?
|
||||
end
|
||||
end
|
||||
|
||||
def paste(str)
|
||||
system('tmux', 'setb', str, ';', 'pasteb', '-t', win, ';', 'send-keys', '-t', win, 'Enter')
|
||||
end
|
||||
@@ -113,6 +181,71 @@ class Tmux
|
||||
go(%W[capture-pane -p -J -t #{win}]).map(&:rstrip).reverse.drop_while(&:empty?).reverse
|
||||
end
|
||||
|
||||
# Raw pane capture with ANSI escape sequences preserved.
|
||||
def capture_ansi
|
||||
go(%W[capture-pane -p -J -e -t #{win}])
|
||||
end
|
||||
|
||||
# 3-bit ANSI bg code (40..47) -> color name used in --color options.
|
||||
BG_NAMES = %w[black red green yellow blue magenta cyan white].freeze
|
||||
|
||||
# Parse `tmux capture-pane -e` output into per-row bg ranges. Each row is an
|
||||
# array of [col_start, col_end, bg] tuples where bg is one of:
|
||||
# 'default'
|
||||
# 'red' / 'green' / 'blue' / ... (3-bit names)
|
||||
# 'bright-red' / ... (bright variants)
|
||||
# '256:<n>' (256-color fallback)
|
||||
# ANSI state persists across rows, matching real terminal behavior.
|
||||
def bg_ranges
|
||||
raw = go(%W[capture-pane -p -J -e -t #{win}])
|
||||
bg = 'default'
|
||||
raw.map do |row|
|
||||
cells = []
|
||||
i = 0
|
||||
len = row.length
|
||||
while i < len
|
||||
c = row[i]
|
||||
if c == "\e" && row[i + 1] == '['
|
||||
j = i + 2
|
||||
j += 1 while j < len && row[j] != 'm'
|
||||
parts = row[i + 2...j].split(';')
|
||||
k = 0
|
||||
while k < parts.length
|
||||
p = parts[k].to_i
|
||||
case p
|
||||
when 0, 49 then bg = 'default'
|
||||
when 40..47 then bg = BG_NAMES[p - 40]
|
||||
when 100..107 then bg = "bright-#{BG_NAMES[p - 100]}"
|
||||
when 48
|
||||
if parts[k + 1] == '5'
|
||||
bg = "256:#{parts[k + 2]}"
|
||||
k += 2
|
||||
elsif parts[k + 1] == '2'
|
||||
bg = "rgb:#{parts[k + 2]}:#{parts[k + 3]}:#{parts[k + 4]}"
|
||||
k += 4
|
||||
end
|
||||
end
|
||||
k += 1
|
||||
end
|
||||
i = j + 1
|
||||
else
|
||||
cells << bg
|
||||
i += 1
|
||||
end
|
||||
end
|
||||
ranges = []
|
||||
start = 0
|
||||
cells.each_with_index do |b, idx|
|
||||
if idx.positive? && b != cells[idx - 1]
|
||||
ranges << [start, idx - 1, cells[idx - 1]]
|
||||
start = idx
|
||||
end
|
||||
end
|
||||
ranges << [start, cells.length - 1, cells.last] unless cells.empty?
|
||||
ranges
|
||||
end
|
||||
end
|
||||
|
||||
def until(refresh = false, timeout: DEFAULT_TIMEOUT)
|
||||
lines = nil
|
||||
begin
|
||||
@@ -160,11 +293,19 @@ class Tmux
|
||||
def prepare
|
||||
tries = 0
|
||||
begin
|
||||
self.until(true) do |lines|
|
||||
if @shell == :nushell
|
||||
message = "Prepare[#{tries}]"
|
||||
send_keys ' ', 'C-u', :Enter, message, :Left, :Right
|
||||
sleep(0.15)
|
||||
lines[-1] == message
|
||||
send_keys 'C-u', 'C-l'
|
||||
sleep(0.2)
|
||||
send_keys ' ', 'C-u', :Enter, message
|
||||
self.until { |lines| lines[-1] == message }
|
||||
else
|
||||
self.until(true) do |lines|
|
||||
message = "Prepare[#{tries}]"
|
||||
send_keys ' ', 'C-u', :Enter, message, :Left, :Right
|
||||
sleep(0.15)
|
||||
lines[-1] == message
|
||||
end
|
||||
end
|
||||
rescue Minitest::Assertion
|
||||
(tries += 1) < 5 ? retry : raise
|
||||
|
||||
+127
-11
@@ -971,6 +971,24 @@ class TestCore < TestInteractive
|
||||
tmux.until { |lines| assert_includes lines[1], ' aabravo/aabravo' }
|
||||
end
|
||||
|
||||
def test_transform_put
|
||||
tmux.send_keys %(seq 1000 | #{FZF} --bind 'a:transform:echo put'), :Enter
|
||||
tmux.until { |lines| assert_equal 1000, lines.match_count }
|
||||
tmux.send_keys :a
|
||||
tmux.until { |lines| assert_equal '> a', lines.last }
|
||||
tmux.send_keys :b
|
||||
tmux.until { |lines| assert_equal '> ab', lines.last }
|
||||
end
|
||||
|
||||
# The async callback runs in a later iteration, but 'put' must still insert
|
||||
# the key that triggered the bg-transform (snapshot of the scheduling event).
|
||||
def test_bg_transform_put
|
||||
tmux.send_keys %(seq 1000 | #{FZF} --bind 'a:bg-transform:sleep 0.5; echo put'), :Enter
|
||||
tmux.until { |lines| assert_equal 1000, lines.match_count }
|
||||
tmux.send_keys 'ab'
|
||||
tmux.until { |lines| assert_equal '> ba', lines.last }
|
||||
end
|
||||
|
||||
def test_accept_non_empty
|
||||
tmux.send_keys %(seq 1000 | #{fzf('--print-query --bind enter:accept-non-empty')}), :Enter
|
||||
tmux.until { |lines| assert_equal 1000, lines.match_count }
|
||||
@@ -1387,6 +1405,85 @@ class TestCore < TestInteractive
|
||||
tmux.until { |lines| assert_includes lines, '> 1' }
|
||||
end
|
||||
|
||||
def test_every_event
|
||||
tmux.send_keys %(seq 100 | fzf --bind 'every(0.2):transform-prompt(cat #{tempname})'), :Enter
|
||||
tmux.until { |lines| assert_equal 100, lines.match_count }
|
||||
# Trigger external state changes; the every() tick should pick them up.
|
||||
writelines(['AAA>'])
|
||||
tmux.until { |lines| assert_includes lines[-1], 'AAA>' }
|
||||
writelines(['BBB>'])
|
||||
tmux.until { |lines| assert_includes lines[-1], 'BBB>' }
|
||||
end
|
||||
|
||||
def test_every_event_multiple_independent_timers
|
||||
# Two timers with different durations should fire independently.
|
||||
fast = tempname + '.fast'
|
||||
slow = tempname + '.slow'
|
||||
FileUtils.rm_f(fast)
|
||||
FileUtils.rm_f(slow)
|
||||
tmux.send_keys %(seq 100 | fzf \\
|
||||
--bind 'every(0.1):execute-silent(printf . >> #{fast})' \\
|
||||
--bind 'every(0.5):execute-silent(printf . >> #{slow})'), :Enter
|
||||
tmux.until { |lines| assert_equal 100, lines.match_count }
|
||||
sleep(1.2)
|
||||
a = File.exist?(fast) ? File.size(fast) : 0
|
||||
b = File.exist?(slow) ? File.size(slow) : 0
|
||||
# Sanity: faster timer fired more times.
|
||||
assert_operator a, :>, b, "fast timer should fire more (#{a} vs #{b})"
|
||||
# Sanity: slow timer fired at least once.
|
||||
assert_operator b, :>=, 1, "slow timer should have fired at least once (#{b})"
|
||||
ensure
|
||||
FileUtils.rm_f(fast)
|
||||
FileUtils.rm_f(slow)
|
||||
end
|
||||
|
||||
def test_every_event_unbind
|
||||
tmux.send_keys %(seq 100 | fzf --bind 'every(0.1):transform-header(date +%S.%N)' --bind 'space:unbind(every(0.1))+change-header(STOPPED)'), :Enter
|
||||
tmux.until { |lines| assert_equal 100, lines.match_count }
|
||||
# Header should be ticking
|
||||
tmux.until { |lines| assert_match(/^ \d{2}\.\d+/, lines[-3]) }
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert_includes lines[-3], 'STOPPED' }
|
||||
sleep(0.4)
|
||||
# Header must stay STOPPED after the unbind
|
||||
assert_includes tmux.capture[-3], 'STOPPED'
|
||||
end
|
||||
|
||||
def test_fzf_idle_time_env
|
||||
# FZF_IDLE_TIME + FZF_IDLE_TIME_MS combined with every() implement idle-based behavior.
|
||||
tmux.send_keys %(seq 100 | fzf --bind 'every(0.2):transform-header(echo "s=$FZF_IDLE_TIME ms_ok=$((FZF_IDLE_TIME_MS / 1000 == FZF_IDLE_TIME))")'), :Enter
|
||||
tmux.until { |lines| assert_equal 100, lines.match_count }
|
||||
# Idle counter advances without any input; ms/1000 stays consistent with seconds.
|
||||
tmux.until { |lines| assert_includes lines[-3], 's=1 ms_ok=1' }
|
||||
tmux.until { |lines| assert_includes lines[-3], 's=2 ms_ok=1' }
|
||||
# Any keystroke resets the counter
|
||||
tmux.send_keys 'x'
|
||||
tmux.until { |lines| assert_includes lines[-3], 's=0 ms_ok=1' }
|
||||
tmux.send_keys :BSpace
|
||||
# And it advances again afterwards
|
||||
tmux.until { |lines| assert_includes lines[-3], 's=1 ms_ok=1' }
|
||||
end
|
||||
|
||||
def test_every_event_rejects_invalid_arg
|
||||
%w[every(0) every(-1) every(abc) every()].each do |spec|
|
||||
tmux.send_keys %(seq 1 | fzf --bind '#{spec}:abort' 2>&1; echo done=$?), :Enter
|
||||
tmux.until { |lines| assert(lines.any? { |l| l.include?('done=2') }) }
|
||||
tmux.send_keys 'clear', :Enter
|
||||
end
|
||||
end
|
||||
|
||||
def test_fzf_key_ignores_synthetic_events
|
||||
tmux.send_keys %(seq 100 | fzf --bind 'every(0.2):transform-prompt(echo "[$FZF_KEY]> ")'), :Enter
|
||||
tmux.until { |lines| assert_equal 100, lines.match_count }
|
||||
# No user input yet: prompt should show empty FZF_KEY
|
||||
tmux.until { |lines| assert_includes lines[-1], '[]>' }
|
||||
tmux.send_keys 'x'
|
||||
tmux.until { |lines| assert_includes lines[-1], '[x]>' }
|
||||
# every() ticks shouldn't overwrite FZF_KEY
|
||||
sleep(1)
|
||||
assert_includes tmux.capture[-1], '[x]>'
|
||||
end
|
||||
|
||||
def test_labels_center
|
||||
tmux.send_keys 'echo x | fzf --border --border-label foobar --preview : --preview-label barfoo --bind "space:change-border-label(foobarfoo)+change-preview-label(barfoobar),enter:transform-border-label(echo foo{}foo)+transform-preview-label(echo bar{}bar)"', :Enter
|
||||
tmux.until do
|
||||
@@ -1672,7 +1769,7 @@ class TestCore < TestInteractive
|
||||
end
|
||||
tmux.send_keys :BSpace, :BSpace, :BSpace
|
||||
|
||||
# Reload with shuffled order — cursor should track "555"
|
||||
# Reload with shuffled order - cursor should track "555"
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until do |lines|
|
||||
assert_equal 1000, lines.match_count
|
||||
@@ -1694,7 +1791,7 @@ class TestCore < TestInteractive
|
||||
tmux.send_keys :Up
|
||||
tmux.until { |lines| assert_includes lines, '> 2 banana' }
|
||||
|
||||
# Reload — the second field changes, but first field "2" stays
|
||||
# Reload - the second field changes, but first field "2" stays
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until do |lines|
|
||||
assert_equal 3, lines.match_count
|
||||
@@ -1709,7 +1806,7 @@ class TestCore < TestInteractive
|
||||
tmux.send_keys :Up
|
||||
tmux.until { |lines| assert_includes lines, '> beta' }
|
||||
|
||||
# Reload with completely different items — no match for "beta"
|
||||
# Reload with completely different items - no match for "beta"
|
||||
# Cursor stays at the same position (second item)
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until do |lines|
|
||||
@@ -1727,7 +1824,7 @@ class TestCore < TestInteractive
|
||||
assert_includes lines[-2], '+T'
|
||||
end
|
||||
|
||||
# Trigger slow reload — should show +T* while blocked
|
||||
# Trigger slow reload - should show +T* while blocked
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until { |lines| assert_includes lines[-2], '+T*' }
|
||||
|
||||
@@ -1769,7 +1866,7 @@ class TestCore < TestInteractive
|
||||
assert_includes lines, '> 1'
|
||||
end
|
||||
|
||||
# Trigger reload — blocked during initial sleep
|
||||
# Trigger reload - blocked during initial sleep
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until { |lines| assert_includes lines[-2], '+T*' }
|
||||
# Match "1" arrives, unblocks before the remaining items load
|
||||
@@ -1790,7 +1887,7 @@ class TestCore < TestInteractive
|
||||
assert_includes lines, '> 1'
|
||||
end
|
||||
|
||||
# Trigger reload-sync — every observable state must be either:
|
||||
# Trigger reload-sync - every observable state must be either:
|
||||
# 1. +T* (still blocked), or
|
||||
# 2. final state (count=10, +T without *)
|
||||
# Any other combination (e.g. unblocked while count < 10) is a bug.
|
||||
@@ -1835,7 +1932,7 @@ class TestCore < TestInteractive
|
||||
tmux.send_keys :Up
|
||||
tmux.until { |lines| assert_includes lines, '> beta' }
|
||||
|
||||
# Reload with completely different items — no match for "beta"
|
||||
# Reload with completely different items - no match for "beta"
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until { |lines| assert_includes lines[-2], '+T*' }
|
||||
# After stream completes, unblocks with cursor at same position (second item)
|
||||
@@ -1857,7 +1954,7 @@ class TestCore < TestInteractive
|
||||
tmux.send_keys 'C-t'
|
||||
tmux.until { |lines| assert_includes lines[-2], '+t' }
|
||||
|
||||
# Reload — should track by field "2"
|
||||
# Reload - should track by field "2"
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until do |lines|
|
||||
assert_equal 3, lines.match_count
|
||||
@@ -1876,7 +1973,7 @@ class TestCore < TestInteractive
|
||||
tmux.send_keys :Up, :Up, :Tab
|
||||
tmux.until { |lines| assert_includes lines[-2], '(2)' }
|
||||
|
||||
# Reload — selections should be preserved by id-nth key
|
||||
# Reload - selections should be preserved by id-nth key
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until do |lines|
|
||||
assert_equal 3, lines.match_count
|
||||
@@ -2074,6 +2171,24 @@ class TestCore < TestInteractive
|
||||
tmux.until { |lines| assert lines.any_include?('a b c') || lines.any_include?('d e f') }
|
||||
end
|
||||
|
||||
# Regression: actions emitted by bg-transform must affect the iteration that
|
||||
# processes the async result, not the (no-longer-active) iteration that
|
||||
# scheduled the transform. Covers reload (newCommand) and exclude (denylist).
|
||||
def test_bg_transform_action_output
|
||||
tmux.send_keys %(seq 5 | #{FZF} --bind 'a:bg-transform(echo reload:seq 10 20),b:bg-transform(echo exclude)'), :Enter
|
||||
tmux.until { |lines| assert_equal 5, lines.item_count }
|
||||
tmux.send_keys :a
|
||||
tmux.until do |lines|
|
||||
assert_equal 11, lines.match_count
|
||||
assert_includes lines, '> 10'
|
||||
end
|
||||
tmux.send_keys :b
|
||||
tmux.until do |lines|
|
||||
assert_equal 10, lines.match_count
|
||||
assert_includes lines, '> 11'
|
||||
end
|
||||
end
|
||||
|
||||
def test_change_with_nth_search
|
||||
input = [
|
||||
'alpha bravo charlie',
|
||||
@@ -2189,6 +2304,7 @@ class TestCore < TestInteractive
|
||||
FZF_ACTION: 'start',
|
||||
FZF_KEY: '',
|
||||
FZF_POS: '1',
|
||||
FZF_CURRENT_ITEM: '1',
|
||||
FZF_QUERY: '',
|
||||
FZF_POINTER: '>',
|
||||
FZF_PROMPT: '> ',
|
||||
@@ -2204,12 +2320,12 @@ class TestCore < TestInteractive
|
||||
end
|
||||
tmux.send_keys :Tab, :Tab
|
||||
tmux.until do
|
||||
expected.merge!(FZF_ACTION: 'toggle-down', FZF_KEY: 'tab', FZF_POS: '3', FZF_SELECT_COUNT: '2')
|
||||
expected.merge!(FZF_ACTION: 'toggle-down', FZF_KEY: 'tab', FZF_POS: '3', FZF_CURRENT_ITEM: '3', FZF_SELECT_COUNT: '2')
|
||||
assert_equal expected, env_vars.slice(*expected.keys)
|
||||
end
|
||||
tmux.send_keys '99'
|
||||
tmux.until do
|
||||
expected.merge!(FZF_ACTION: 'char', FZF_KEY: '9', FZF_QUERY: '99', FZF_MATCH_COUNT: '1', FZF_POS: '1')
|
||||
expected.merge!(FZF_ACTION: 'char', FZF_KEY: '9', FZF_QUERY: '99', FZF_MATCH_COUNT: '1', FZF_POS: '1', FZF_CURRENT_ITEM: '99')
|
||||
assert_equal expected, env_vars.slice(*expected.keys)
|
||||
end
|
||||
tmux.send_keys :Space
|
||||
|
||||
+508
-60
@@ -243,6 +243,90 @@ class TestLayout < TestInteractive
|
||||
tmux.until { assert_block(expected, it) }
|
||||
end
|
||||
|
||||
def test_preview_window_next_reverse
|
||||
# https://github.com/junegunn/fzf/issues/4798
|
||||
tmux.send_keys %(seq 5 | #{FZF} --layout=reverse --preview 'echo PREVIEW' --preview-window=next:3 --prompt='line2$ > '), :Enter
|
||||
expected = <<~OUTPUT
|
||||
line2$ >
|
||||
5/5 ───
|
||||
╭────────
|
||||
│ PREVIEW
|
||||
│
|
||||
│
|
||||
╰────────
|
||||
> 1
|
||||
OUTPUT
|
||||
tmux.until { assert_block(expected, it) }
|
||||
end
|
||||
|
||||
def test_preview_window_next_default
|
||||
tmux.send_keys %(seq 5 | #{FZF} --preview 'echo PREVIEW' --preview-window=next:3), :Enter
|
||||
expected = <<~OUTPUT
|
||||
> 1
|
||||
╭────────
|
||||
│ PREVIEW
|
||||
│
|
||||
│
|
||||
╰────────
|
||||
5/5 ───
|
||||
>
|
||||
OUTPUT
|
||||
tmux.until { assert_block(expected, it) }
|
||||
end
|
||||
|
||||
def test_preview_window_next_border_line_at_runtime
|
||||
# change-preview-window to next,border-line should resolve BorderLine
|
||||
# to a single horizontal separator, matching the behavior
|
||||
# when next,border-line is the initial spec.
|
||||
tmux.send_keys %(seq 5 | #{FZF} --preview 'echo PREVIEW' --bind 'space:change-preview-window:next:3,border-line'), :Enter
|
||||
tmux.until { |lines| assert_equal 5, lines.match_count }
|
||||
tmux.send_keys :Space
|
||||
expected = <<~OUTPUT
|
||||
> 1
|
||||
───────
|
||||
PREVIEW
|
||||
OUTPUT
|
||||
tmux.until do |lines|
|
||||
cursor = lines.index { it.start_with?('> 1') }
|
||||
assert(cursor)
|
||||
assert_block(expected, lines[cursor..])
|
||||
end
|
||||
end
|
||||
|
||||
def test_header_first_change_header_at_runtime
|
||||
# --header-first with no initial --header content needs to grow a
|
||||
# header window when change-header adds content at runtime, so the
|
||||
# new header lands below the prompt (not on top of it).
|
||||
tmux.send_keys %(seq 5 | #{FZF} --header-first --bind 'space:change-header:foo'), :Enter
|
||||
tmux.until { |lines| assert_equal 5, lines.match_count }
|
||||
tmux.send_keys :Space
|
||||
expected = <<~OUTPUT
|
||||
>
|
||||
foo
|
||||
OUTPUT
|
||||
tmux.until do |lines|
|
||||
prompt = lines.index { it.start_with?('>') }
|
||||
assert(prompt)
|
||||
assert_block(expected, lines[prompt..])
|
||||
end
|
||||
end
|
||||
|
||||
def test_preview_window_next_style_full_line
|
||||
tmux.send_keys %(seq 5 | #{FZF} --reverse --preview 'echo PREVIEW' --preview-window=next:3 --header foo --footer bar --style full:line), :Enter
|
||||
expected = <<~OUTPUT
|
||||
>
|
||||
───────
|
||||
PREVIEW
|
||||
|
||||
|
||||
───────
|
||||
foo
|
||||
───────
|
||||
> 1
|
||||
OUTPUT
|
||||
tmux.until { assert_block(expected, it) }
|
||||
end
|
||||
|
||||
def test_height_range_overflow
|
||||
tmux.send_keys 'seq 100 | fzf --height ~5 --info=inline --border rounded', :Enter
|
||||
expected = <<~OUTPUT
|
||||
@@ -1227,75 +1311,439 @@ class TestLayout < TestInteractive
|
||||
def test_combinations
|
||||
skip unless ENV['LONGTEST']
|
||||
|
||||
base = [
|
||||
'--pointer=@',
|
||||
'--exact',
|
||||
'--query=123',
|
||||
'--header="$(seq 101 103)"',
|
||||
'--header-lines=3',
|
||||
'--footer "$(seq 201 203)"',
|
||||
'--preview "echo foobar"'
|
||||
]
|
||||
options = [
|
||||
['--separator==', '--no-separator'],
|
||||
['--info=default', '--info=inline', '--info=inline-right'],
|
||||
['--no-input-border', '--input-border'],
|
||||
['--no-header-border', '--header-border=none', '--header-border'],
|
||||
['--no-header-lines-border', '--header-lines-border'],
|
||||
['--no-footer-border', '--footer-border'],
|
||||
['--no-list-border', '--list-border'],
|
||||
['--preview-window=right', '--preview-window=up', '--preview-window=down', '--preview-window=left'],
|
||||
['--header-first', '--no-header-first'],
|
||||
['--layout=default', '--layout=reverse', '--layout=reverse-list']
|
||||
]
|
||||
# Combination of all options
|
||||
combinations = options[0].product(*options.drop(1))
|
||||
combinations.each_with_index do |combination, index|
|
||||
opts = base + combination
|
||||
command = %(seq 1001 2000 | #{FZF} #{opts.join(' ')})
|
||||
puts "# #{index + 1}/#{combinations.length}\n#{command}"
|
||||
tmux.send_keys command, :Enter
|
||||
tmux.until do |lines|
|
||||
layout = combination.find { it.start_with?('--layout=') }.split('=').last
|
||||
header_first = combination.include?('--header-first')
|
||||
begin
|
||||
base = [
|
||||
'--pointer=@',
|
||||
'--exact',
|
||||
'--query=123',
|
||||
'--header="$(seq 101 103)"',
|
||||
'--header-lines=3',
|
||||
'--footer "$(seq 201 203)"',
|
||||
'--preview "echo foobar"'
|
||||
]
|
||||
options = [
|
||||
['--separator==', '--no-separator'],
|
||||
['--info=default', '--info=inline', '--info=inline-right'],
|
||||
['--no-input-border', '--input-border'],
|
||||
['--no-header-border', '--header-border=none', '--header-border'],
|
||||
['--no-header-lines-border', '--header-lines-border'],
|
||||
['--no-footer-border', '--footer-border'],
|
||||
['--no-list-border', '--list-border'],
|
||||
['--preview-window=right', '--preview-window=up', '--preview-window=down', '--preview-window=left', '--preview-window=next'],
|
||||
['--header-first', '--no-header-first'],
|
||||
['--layout=default', '--layout=reverse', '--layout=reverse-list']
|
||||
]
|
||||
# Combination of all options
|
||||
combinations = options[0].product(*options.drop(1))
|
||||
|
||||
# Input
|
||||
input = lines.index { it.include?('> 123') }
|
||||
assert(input)
|
||||
# Run workers in parallel, each with its own pre-created tmux window.
|
||||
# Tmux setup/teardown is serialized in the main thread to avoid racing
|
||||
# `tmux new-window` and `tmux kill-window` calls on the tmux server.
|
||||
workers = 10
|
||||
tmuxes = Array.new(workers) { Tmux.new }
|
||||
failures = []
|
||||
mutex = Mutex.new
|
||||
queue = Queue.new
|
||||
index = 0
|
||||
threads = tmuxes.map do |local_tmux|
|
||||
Thread.new do
|
||||
command = nil
|
||||
loop do
|
||||
combination = queue.pop or break
|
||||
|
||||
# Info
|
||||
info = lines.index { it.include?('11/997') }
|
||||
assert(info)
|
||||
opts = base + combination
|
||||
command = %(seq 1001 2000 | #{FZF} #{opts.join(' ')})
|
||||
mutex.synchronize do
|
||||
print("\r#{index += 1}/#{combinations.length}")
|
||||
end
|
||||
local_tmux.send_keys command, :Enter
|
||||
local_tmux.until do |lines|
|
||||
layout = combination.find { it.start_with?('--layout=') }.split('=').last
|
||||
header_first = combination.include?('--header-first')
|
||||
|
||||
assert(layout == 'reverse' ? input <= info : input >= info)
|
||||
# Input
|
||||
input = lines.index { it.include?('> 123') }
|
||||
assert(input)
|
||||
|
||||
# List
|
||||
item1 = lines.index { it.include?('1230') }
|
||||
item2 = lines.index { it.include?('1231') }
|
||||
assert_equal(item1, layout == 'default' ? item2 + 1 : item2 - 1)
|
||||
# Info
|
||||
info = lines.index { it.include?('11/997') }
|
||||
assert(info)
|
||||
|
||||
# Preview
|
||||
assert(lines.any? { it.include?('foobar') })
|
||||
assert(layout == 'reverse' ? input <= info : input >= info)
|
||||
|
||||
# Header
|
||||
header1 = lines.index { it.include?('101') }
|
||||
header2 = lines.index { it.include?('102') }
|
||||
assert_equal(header2, header1 + 1)
|
||||
assert((layout == 'reverse') == header_first ? input > header1 : input < header1)
|
||||
# List
|
||||
item1 = lines.index { it.include?('1230') }
|
||||
item2 = lines.index { it.include?('1231') }
|
||||
assert_equal(item1, layout == 'default' ? item2 + 1 : item2 - 1)
|
||||
|
||||
# Footer
|
||||
footer1 = lines.index { it.include?('201') }
|
||||
footer2 = lines.index { it.include?('202') }
|
||||
assert_equal(footer2, footer1 + 1)
|
||||
assert(layout == 'reverse' ? footer1 > item2 : footer1 < item2)
|
||||
# Preview
|
||||
assert(lines.any? { it.include?('foobar') })
|
||||
|
||||
# Header lines
|
||||
hline1 = lines.index { it.include?('1001') }
|
||||
hline2 = lines.index { it.include?('1002') }
|
||||
assert_equal(hline1, layout == 'default' ? hline2 + 1 : hline2 - 1)
|
||||
assert(layout == 'reverse' ? hline1 > header1 : hline1 < header1)
|
||||
# Header
|
||||
header1 = lines.index { it.include?('101') }
|
||||
header2 = lines.index { it.include?('102') }
|
||||
assert_equal(header2, header1 + 1)
|
||||
assert((layout == 'reverse') == header_first ? input > header1 : input < header1)
|
||||
|
||||
# Footer
|
||||
footer1 = lines.index { it.include?('201') }
|
||||
footer2 = lines.index { it.include?('202') }
|
||||
assert_equal(footer2, footer1 + 1)
|
||||
assert(layout == 'reverse' ? footer1 > item2 : footer1 < item2)
|
||||
|
||||
# Header lines
|
||||
hline1 = lines.index { it.include?('1001') }
|
||||
hline2 = lines.index { it.include?('1002') }
|
||||
assert_equal(hline1, layout == 'default' ? hline2 + 1 : hline2 - 1)
|
||||
assert(layout == 'reverse' ? hline1 > header1 : hline1 < header1)
|
||||
end
|
||||
local_tmux.send_keys :Enter
|
||||
end
|
||||
rescue StandardError, Minitest::Assertion => e
|
||||
mutex.synchronize { failures << [command, e] }
|
||||
end
|
||||
end
|
||||
tmux.send_keys :Enter
|
||||
combinations.each { queue << it }
|
||||
queue.close
|
||||
|
||||
threads.each(&:join)
|
||||
raise failures.inspect unless failures.empty?
|
||||
ensure
|
||||
# Reverse so any tmux window renumbering does not leave stale indices behind.
|
||||
tmuxes&.reverse_each(&:kill)
|
||||
end
|
||||
end
|
||||
|
||||
# Locate a word in the currently captured screen and click its first character.
|
||||
# tmux rows/columns are 1-based; capture indices are 0-based.
|
||||
def click_word(word)
|
||||
tmux.capture.each_with_index do |line, idx|
|
||||
col = line.index(word)
|
||||
return tmux.click(col + 1, idx + 1) if col
|
||||
end
|
||||
flunk("word #{word.inspect} not found on screen")
|
||||
end
|
||||
|
||||
# Launch fzf with a click-{header,footer} binding that echoes FZF_CLICK_* into the prompt,
|
||||
# then click each word in `clicks` and assert the resulting L/W values.
|
||||
# `clicks` is an array of [word_to_click, expected_line].
|
||||
def verify_clicks(kind:, opts:, input:, clicks:)
|
||||
var = kind.to_s.upcase # HEADER or FOOTER
|
||||
binding = "click-#{kind}:transform-prompt:" \
|
||||
"echo \"L=$FZF_CLICK_#{var}_LINE W=$FZF_CLICK_#{var}_WORD> \""
|
||||
# --multi makes the info line end in " (0)" so the wait regex is unambiguous.
|
||||
tmux.send_keys %(#{input} | #{FZF} #{opts} --multi --bind '#{binding}'), :Enter
|
||||
# Wait for fzf to fully render before inspecting the screen, otherwise the echoed
|
||||
# command line can shadow click targets.
|
||||
tmux.until { |lines| lines.any_include?(%r{ [0-9]+/[0-9]+ \(0\)}) }
|
||||
clicks.each do |word, line|
|
||||
click_word(word)
|
||||
tmux.until { |lines| assert lines.any_include?("L=#{line} W=#{word}>") }
|
||||
end
|
||||
tmux.send_keys 'Escape'
|
||||
end
|
||||
|
||||
# Header lines (--header-lines) are rendered in reverse display order only under
|
||||
# layout=default; in layout=reverse and layout=reverse-list they keep the input order.
|
||||
# FZF_CLICK_HEADER_LINE reflects the visual row, so the expected value flips.
|
||||
HEADER_CLICKS = [%w[Aaa 1], %w[Bbb 2], %w[Ccc 3]].freeze
|
||||
|
||||
%w[default reverse reverse-list].each do |layout|
|
||||
slug = layout.tr('-', '_')
|
||||
|
||||
# Plain --header with no border around the header section.
|
||||
define_method(:"test_click_header_plain_#{slug}") do
|
||||
verify_clicks(kind: :header,
|
||||
opts: %(--layout=#{layout} --header $'Aaa\\nBbb\\nCcc'),
|
||||
input: 'seq 5',
|
||||
clicks: HEADER_CLICKS)
|
||||
end
|
||||
|
||||
# --header with a framing border (--style full gives --header-border=rounded by default).
|
||||
define_method(:"test_click_header_border_rounded_#{slug}") do
|
||||
verify_clicks(kind: :header,
|
||||
opts: %(--layout=#{layout} --style full --header $'Aaa\\nBbb\\nCcc'),
|
||||
input: 'seq 5',
|
||||
clicks: HEADER_CLICKS)
|
||||
end
|
||||
|
||||
# --header-lines consumed from stdin, with its own framing border.
|
||||
define_method(:"test_click_header_lines_border_rounded_#{slug}") do
|
||||
clicks_hl = if layout == 'default'
|
||||
[%w[Xaa 3], %w[Ybb 2], %w[Zcc 1]]
|
||||
else
|
||||
[%w[Xaa 1], %w[Ybb 2], %w[Zcc 3]]
|
||||
end
|
||||
verify_clicks(kind: :header,
|
||||
opts: %(--layout=#{layout} --style full --header-lines 3),
|
||||
input: "(printf 'Xaa\\nYbb\\nZcc\\n'; seq 5)",
|
||||
clicks: clicks_hl)
|
||||
end
|
||||
|
||||
# --footer with a framing border.
|
||||
define_method(:"test_click_footer_border_rounded_#{slug}") do
|
||||
verify_clicks(kind: :footer,
|
||||
opts: %(--layout=#{layout} --style full --footer $'Foo\\nBar\\nBaz'),
|
||||
input: 'seq 5',
|
||||
clicks: [%w[Foo 1], %w[Bar 2], %w[Baz 3]])
|
||||
end
|
||||
|
||||
# --header and --header-lines combined. Click-header numbering concatenates the two
|
||||
# sections, but the order depends on the layout:
|
||||
# layoutReverse: custom header (1..N), then header-lines (N+1..N+M)
|
||||
# layoutDefault: header-lines (1..M, reversed visually), then custom header (M+1..M+N)
|
||||
# layoutReverseList: header-lines (1..M), then custom header (M+1..M+N)
|
||||
define_method(:"test_click_header_combined_#{slug}") do
|
||||
clicks = case layout
|
||||
when 'reverse'
|
||||
[%w[Aaa 1], %w[Bbb 2], %w[Ccc 3], %w[Xaa 4], %w[Ybb 5], %w[Zcc 6]]
|
||||
when 'default'
|
||||
[%w[Aaa 4], %w[Bbb 5], %w[Ccc 6], %w[Xaa 3], %w[Ybb 2], %w[Zcc 1]]
|
||||
else # reverse-list
|
||||
[%w[Aaa 4], %w[Bbb 5], %w[Ccc 6], %w[Xaa 1], %w[Ybb 2], %w[Zcc 3]]
|
||||
end
|
||||
verify_clicks(kind: :header,
|
||||
opts: %(--layout=#{layout} --header $'Aaa\\nBbb\\nCcc' --header-lines 3),
|
||||
input: "(printf 'Xaa\\nYbb\\nZcc\\n'; seq 5)",
|
||||
clicks: clicks)
|
||||
end
|
||||
|
||||
# Inline header inside a rounded list border.
|
||||
define_method(:"test_click_header_border_inline_#{slug}") do
|
||||
opts = %(--layout=#{layout} --style full --header $'Aaa\\nBbb\\nCcc' --header-border=inline)
|
||||
verify_clicks(kind: :header, opts: opts, input: 'seq 5', clicks: HEADER_CLICKS)
|
||||
end
|
||||
|
||||
# Inline header inside a horizontal list border (top+bottom only, no T-junctions).
|
||||
define_method(:"test_click_header_border_inline_horizontal_list_#{slug}") do
|
||||
opts = %(--layout=#{layout} --style full --list-border=horizontal --header $'Aaa\\nBbb\\nCcc' --header-border=inline)
|
||||
verify_clicks(kind: :header, opts: opts, input: 'seq 5', clicks: HEADER_CLICKS)
|
||||
end
|
||||
|
||||
# Inline header-lines inside a rounded list border.
|
||||
define_method(:"test_click_header_lines_border_inline_#{slug}") do
|
||||
clicks_hl = if layout == 'default'
|
||||
[%w[Xaa 3], %w[Ybb 2], %w[Zcc 1]]
|
||||
else
|
||||
[%w[Xaa 1], %w[Ybb 2], %w[Zcc 3]]
|
||||
end
|
||||
opts = %(--layout=#{layout} --style full --header-lines 3 --header-lines-border=inline)
|
||||
verify_clicks(kind: :header, opts: opts,
|
||||
input: "(printf 'Xaa\\nYbb\\nZcc\\n'; seq 5)",
|
||||
clicks: clicks_hl)
|
||||
end
|
||||
|
||||
# Inline footer inside a rounded list border.
|
||||
define_method(:"test_click_footer_border_inline_#{slug}") do
|
||||
opts = %(--layout=#{layout} --style full --footer $'Foo\\nBar\\nBaz' --footer-border=inline)
|
||||
verify_clicks(kind: :footer, opts: opts, input: 'seq 5',
|
||||
clicks: [%w[Foo 1], %w[Bar 2], %w[Baz 3]])
|
||||
end
|
||||
end
|
||||
|
||||
# An inline section requesting far more rows than the terminal can fit must not
|
||||
# break the layout. The list frame must still render inside the pane with both
|
||||
# corners visible and the prompt line present.
|
||||
def test_inline_header_lines_oversized
|
||||
tmux.send_keys %(seq 10000 | #{FZF} --style full --header-border inline --header-lines 9999), :Enter
|
||||
tmux.until { |lines| lines.any_include?(%r{ [0-9]+/[0-9]+}) }
|
||||
lines = tmux.capture
|
||||
# Rounded (light) and sharp (tcell) default border glyphs.
|
||||
top_corners = /[╭┌]/
|
||||
bottom_corners = /[╰└]/
|
||||
assert(lines.any? { |l| l.match?(top_corners) }, "list frame top missing: #{lines.inspect}")
|
||||
assert(lines.any? { |l| l.match?(bottom_corners) }, "list frame bottom missing: #{lines.inspect}")
|
||||
assert(lines.any? { |l| l.include?('>') }, "prompt missing: #{lines.inspect}")
|
||||
tmux.send_keys 'Escape'
|
||||
end
|
||||
|
||||
# A non-inline section that consumes all available rows must still render without
|
||||
# crashing when another section is inline but has no budget. The inline section's
|
||||
# content is clipped to 0 but the layout proceeds.
|
||||
def test_inline_footer_starved_by_non_inline_header
|
||||
tmux.send_keys %(seq 10000 | #{FZF} --style full --footer-border inline --footer "$(seq 1000)" --header "$(seq 1000)"), :Enter
|
||||
tmux.until { |lines| lines.any_include?(%r{ [0-9]+/[0-9]+}) }
|
||||
lines = tmux.capture
|
||||
assert(lines.any? { |l| l.include?('>') }, "prompt missing: #{lines.inspect}")
|
||||
tmux.send_keys 'Escape'
|
||||
end
|
||||
|
||||
# Without a line-drawing --list-border, --header-border=inline must silently
|
||||
# fall back to the `line` style (documented behavior).
|
||||
def test_inline_falls_back_without_list_border
|
||||
tmux.send_keys %(seq 5 | #{FZF} --list-border=none --header HEADER --header-border=inline), :Enter
|
||||
tmux.until { |lines| lines.any_include?(%r{ [0-9]+/[0-9]+}) }
|
||||
lines = tmux.capture
|
||||
assert(lines.any? { |l| l.include?('HEADER') }, "header missing: #{lines.inspect}")
|
||||
# Neither list frame corners (rounded/sharp) nor T-junction runes appear,
|
||||
# since we've fallen back to a plain line separator.
|
||||
assert(lines.none? { |l| l.match?(/[╭╮╰╯┌┐└┘├┤]/) }, "unexpected frame glyphs: #{lines.inspect}")
|
||||
tmux.send_keys 'Escape'
|
||||
end
|
||||
|
||||
# Regression: when --header-border=inline falls back to `line` because the
|
||||
# list border can't host an inline separator, the header-border color must
|
||||
# inherit from `border`, not `list-border`. The effective shape is `line`,
|
||||
# so color inheritance must match what `line` rendering would use.
|
||||
def test_inline_fallback_does_not_inherit_list_border_color
|
||||
# Marker attribute (bold) on list-border. If HeaderBorder wrongly inherits
|
||||
# from ListBorder, the header separator characters will carry the bold
|
||||
# attribute. --info=hidden and --no-separator strip other separator lines
|
||||
# so the only row of `─` chars is the header separator.
|
||||
tmux.send_keys %(seq 5 | #{FZF} --list-border=none --header HEADER --header-border=inline --info=hidden --no-separator --color=bg:-1,list-border:red:bold), :Enter
|
||||
sep_row = nil
|
||||
tmux.until do |_|
|
||||
sep_row = tmux.capture_ansi.find do |row|
|
||||
stripped = row.gsub(/\e\[[\d;]*m/, '').rstrip
|
||||
stripped.match?(/\A─+\z/)
|
||||
end
|
||||
!sep_row.nil?
|
||||
end
|
||||
# Bold (1) or red fg (31) on the header separator means it inherited from
|
||||
# list-border even though the effective shape is `line` (non-inline).
|
||||
refute_match(/\e\[(?:[\d;]*;)?(?:1|31)(?:;[\d;]*)?m─/, sep_row,
|
||||
"header separator inherited list-border attr: #{sep_row.inspect}")
|
||||
tmux.send_keys 'Escape'
|
||||
end
|
||||
|
||||
# Inline takes precedence over --header-first: the main header stays
|
||||
# inside the list frame instead of moving below the input.
|
||||
def test_inline_header_border_overrides_header_first
|
||||
tmux.send_keys %(seq 5 | #{FZF} --style full --header foo --header-first --header-border inline), :Enter
|
||||
tmux.until do |lines|
|
||||
foo_idx = lines.index { |l| l.match?(/\A│\s+foo\s+│\z/) }
|
||||
input_idx = lines.index { |l| l.match?(%r{\A│\s+>\s+\d+/\d+\s+│\z}) }
|
||||
foo_idx && input_idx && foo_idx < input_idx
|
||||
end
|
||||
end
|
||||
|
||||
# With both sections present, --header-first still moves the main --header
|
||||
# below the input while --header-lines-border=inline keeps header-lines
|
||||
# inside the list frame.
|
||||
def test_inline_header_lines_with_header_first_and_main_header
|
||||
tmux.send_keys %(seq 5 | #{FZF} --style full --header foo --header-lines 1 --header-first --header-lines-border inline), :Enter
|
||||
tmux.until do |lines|
|
||||
one_idx = lines.index { |l| l.match?(/\A│\s+1\s+│\z/) }
|
||||
foo_idx = lines.index { |l| l.match?(/\A│\s+foo\s+│\z/) }
|
||||
input_idx = lines.index { |l| l.match?(%r{\A│\s+>\s+\d+/\d+\s+│\z}) }
|
||||
one_idx && foo_idx && input_idx && one_idx < input_idx && input_idx < foo_idx
|
||||
end
|
||||
end
|
||||
|
||||
# With no main --header, --header-first previously repositioned
|
||||
# header-lines. Inline now takes precedence: header-lines stays inside
|
||||
# the list frame.
|
||||
def test_inline_header_lines_with_header_first_no_main_header
|
||||
tmux.send_keys %(seq 5 | #{FZF} --style full --header-lines 1 --header-first --header-lines-border inline), :Enter
|
||||
tmux.until do |lines|
|
||||
one_idx = lines.index { |l| l.match?(/\A│\s+1\s+│\z/) }
|
||||
input_idx = lines.index { |l| l.match?(%r{\A│\s+>\s+\d+/\d+\s+│\z}) }
|
||||
one_idx && input_idx && one_idx < input_idx
|
||||
end
|
||||
end
|
||||
|
||||
# Regression: with --header-border=inline and --header-lines but no
|
||||
# --header, the inline slot was sized for header-lines only. After
|
||||
# change-header added a main header line, resizeIfNeeded tolerated the
|
||||
# too-small slot, so the header-lines line got displaced and disappeared.
|
||||
def test_inline_change_header_grows_slot
|
||||
tmux.send_keys %(seq 5 | #{FZF} --style full --header-lines 1 --header-border inline --bind space:change-header:tada), :Enter
|
||||
tmux.until { |lines| lines.any_include?(/\A│\s+1\s+│\z/) }
|
||||
tmux.send_keys :Space
|
||||
tmux.until do |lines|
|
||||
lines.any_include?(/\A│\s+1\s+│\z/) && lines.any_include?(/\A│\s+tada\s+│\z/)
|
||||
end
|
||||
end
|
||||
|
||||
# Regression: with --footer-border=inline, change-footer that grows the
|
||||
# footer line count left the inline slot sized for the old length, so
|
||||
# extra lines were clipped.
|
||||
def test_inline_change_footer_grows_slot
|
||||
tmux.send_keys %(seq 5 | #{FZF} --style full --footer-border inline --footer one --bind $'space:change-footer:one\\ntwo'), :Enter
|
||||
tmux.until { |lines| lines.any_include?(/\A│\s+one\s+│\z/) }
|
||||
tmux.send_keys :Space
|
||||
tmux.until do |lines|
|
||||
lines.any_include?(/\A│\s+one\s+│\z/) && lines.any_include?(/\A│\s+two\s+│\z/)
|
||||
end
|
||||
end
|
||||
|
||||
# Invalid inline combinations must be rejected at startup.
|
||||
def test_inline_rejected_on_unsupported_options
|
||||
[
|
||||
['--border=inline', 'inline border is only supported'],
|
||||
['--list-border=inline', 'inline border is only supported'],
|
||||
['--input-border=inline', 'inline border is only supported'],
|
||||
['--preview-window=border-inline --preview :', 'invalid preview window option: border-inline'],
|
||||
['--header-border=inline --header-lines-border=sharp --header-lines=1',
|
||||
'--header-border=inline requires --header-lines-border to be inline or unset']
|
||||
].each do |args, expected|
|
||||
output = `#{FZF} #{args} < /dev/null 2>&1`
|
||||
refute_equal 0, $CHILD_STATUS.exitstatus, "expected non-zero exit for: #{args}"
|
||||
assert_includes output, expected, "wrong error for: #{args}"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Count rows whose entire width is a single `color` range.
|
||||
def count_full_rows(ranges_by_row, color)
|
||||
ranges_by_row.count { |r| r.length == 1 && r[0][2] == color }
|
||||
end
|
||||
|
||||
# Wait until `tmux.bg_ranges` has at least `count` fully-`color` rows; return them.
|
||||
def wait_for_full_rows(color, count)
|
||||
ranges = nil
|
||||
tmux.until do |_|
|
||||
ranges = tmux.bg_ranges
|
||||
count_full_rows(ranges, color) >= count
|
||||
end
|
||||
ranges
|
||||
end
|
||||
|
||||
public
|
||||
|
||||
# Inline header's entire section (outer edge + content-row verticals + separator)
|
||||
# carries the header-bg color; list rows below carry list-bg.
|
||||
def test_inline_header_bg_color
|
||||
tmux.send_keys %(seq 5 | #{FZF} --list-border --reverse --header HEADER --header-border=inline --color=bg:-1,header-border:white,list-border:white,header-bg:red,list-bg:green), :Enter
|
||||
tmux.until { |lines| lines.any_include?(%r{ [0-9]+/[0-9]+}) }
|
||||
# 3 fully-red rows: top edge, header content, separator.
|
||||
ranges = wait_for_full_rows('red', 3)
|
||||
assert_equal_org(3, count_full_rows(ranges, 'red'))
|
||||
# List rows below (>=5) are fully green.
|
||||
assert_operator count_full_rows(ranges, 'green'), :>=, 5
|
||||
tmux.send_keys 'Escape'
|
||||
end
|
||||
|
||||
# Regression: when --header-lines-border=inline is the only inline section
|
||||
# (no --header-border), the section must still use header-bg, not list-bg.
|
||||
def test_inline_header_lines_bg_without_main_header
|
||||
tmux.send_keys %(seq 5 | #{FZF} --list-border --reverse --header-lines 2 --header-lines-border=inline --color=bg:-1,header-border:white,list-border:white,header-bg:red,list-bg:green), :Enter
|
||||
tmux.until { |lines| lines.any_include?(%r{ [0-9]+/[0-9]+}) }
|
||||
# Top edge + 2 content rows + separator = 4 fully-red rows.
|
||||
ranges = wait_for_full_rows('red', 4)
|
||||
assert_equal_org(4, count_full_rows(ranges, 'red'))
|
||||
tmux.send_keys 'Escape'
|
||||
end
|
||||
|
||||
# Inline footer's entire section carries footer-bg; list rows above carry list-bg.
|
||||
def test_inline_footer_bg_color
|
||||
tmux.send_keys %(seq 5 | #{FZF} --list-border --footer FOOTER --footer-border=inline --color=bg:-1,footer-border:white,list-border:white,footer-bg:blue,list-bg:green), :Enter
|
||||
tmux.until { |lines| lines.any_include?(%r{ [0-9]+/[0-9]+}) }
|
||||
ranges = wait_for_full_rows('blue', 3)
|
||||
assert_equal_org(3, count_full_rows(ranges, 'blue'))
|
||||
tmux.send_keys 'Escape'
|
||||
end
|
||||
|
||||
# The list-label's bg is swapped to match the adjacent inline section so it reads as
|
||||
# part of the section frame rather than a list-colored island on a section-colored edge.
|
||||
def test_list_label_bg_on_inline_section_edge
|
||||
tmux.send_keys %(seq 5 | #{FZF} --list-border --reverse --header HEADER --header-border=inline --list-label=LL --color=bg:-1,header-border:white,list-border:white,header-bg:red,list-bg:green,list-label:yellow:bold), :Enter
|
||||
tmux.until { |lines| lines.any_include?(%r{ [0-9]+/[0-9]+}) }
|
||||
# The label sits on the header-owned top edge, so the entire row must be a
|
||||
# single red run (no green breaks where the label cells are).
|
||||
ranges = wait_for_full_rows('red', 3)
|
||||
assert_operator count_full_rows(ranges, 'red'), :>=, 3
|
||||
tmux.send_keys 'Escape'
|
||||
end
|
||||
end
|
||||
|
||||
@@ -383,6 +383,49 @@ class TestPreview < TestInteractive
|
||||
end
|
||||
end
|
||||
|
||||
def test_change_preview_window_preserves_wrap_toggle
|
||||
# https://github.com/junegunn/fzf/issues/4791
|
||||
tmux.send_keys "#{FZF} --preview 'for i in $(seq $FZF_PREVIEW_COLUMNS); do echo -n .; done; echo -n .; echo wrapped; echo 2nd line' " \
|
||||
"--preview-window 'right,nowrap,border-rounded' " \
|
||||
'--bind ctrl-w:toggle-preview-wrap ' \
|
||||
'--bind ctrl-r:change-preview-window:border-bold', :Enter
|
||||
sleep(2)
|
||||
# Initial: nowrap, rounded border. The long line is truncated; "wrapped" is hidden.
|
||||
tmux.until do |lines|
|
||||
assert_includes lines[2], '2nd line'
|
||||
assert(lines.any? { it.include?('╭') })
|
||||
end
|
||||
# Toggle wrap on.
|
||||
tmux.send_keys 'C-w'
|
||||
tmux.until do |lines|
|
||||
assert_includes lines[2], 'wrapped'
|
||||
assert_includes lines[3], '2nd line'
|
||||
end
|
||||
# change-preview-window swaps the border to bold; wrap state must persist.
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until do |lines|
|
||||
assert(lines.any? { it.include?('┏') }) # border actually changed
|
||||
refute(lines.any? { it.include?('╭') })
|
||||
assert_includes lines[2], 'wrapped' # wrap was preserved
|
||||
assert_includes lines[3], '2nd line'
|
||||
end
|
||||
end
|
||||
|
||||
def test_change_preview_window_overrides_wrap_explicitly
|
||||
# When the new spec sets wrap/nowrap explicitly, it should still win.
|
||||
tmux.send_keys "#{FZF} --preview 'for i in $(seq $FZF_PREVIEW_COLUMNS); do echo -n .; done; echo -n .; echo wrapped; echo 2nd line' " \
|
||||
"--preview-window 'right,wrap' " \
|
||||
'--bind ctrl-r:change-preview-window:nowrap', :Enter
|
||||
# Initial: wrap is on.
|
||||
tmux.until do |lines|
|
||||
assert_includes lines[2], 'wrapped'
|
||||
assert_includes lines[3], '2nd line'
|
||||
end
|
||||
# Explicit nowrap in the spec must override the (initially wrapped) state.
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until { |lines| assert_includes lines[2], '2nd line' }
|
||||
end
|
||||
|
||||
def test_preview_follow_wrap
|
||||
tmux.send_keys "seq 1 | #{FZF} --preview 'seq 1000' --preview-window right,2,follow,wrap", :Enter
|
||||
tmux.until { |lines| assert_equal 1, lines.match_count }
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ class TestServer < TestInteractive
|
||||
assert_equal [0, 1], state[:current][:positions]
|
||||
assert_equal state[:current][:positions], state[:current][:positions].sort
|
||||
|
||||
# No match — no current item
|
||||
# No match - no current item
|
||||
Net::HTTP.post(fn.call, 'change-query(yo)+reload(seq 100)+change-prompt:hundred> ')
|
||||
tmux.until { |lines| assert_equal 100, lines.item_count }
|
||||
tmux.until { |lines| assert_equal 'hundred> yo', lines[-1] }
|
||||
|
||||
@@ -100,6 +100,47 @@ module TestShell
|
||||
tmux.until { |lines| assert_equal '/tmp', lines[-1] }
|
||||
end
|
||||
|
||||
def test_alt_c_symlink
|
||||
base = '/tmp/fzf-test-alt-c-symlink'
|
||||
FileUtils.rm_rf(base)
|
||||
FileUtils.mkdir_p("#{base}/real/subdir")
|
||||
FileUtils.ln_s("#{base}/real", "#{base}/link")
|
||||
|
||||
tmux.prepare
|
||||
tmux.send_keys "cd #{base}/link", :Enter
|
||||
tmux.prepare
|
||||
tmux.send_keys :Escape, :c
|
||||
tmux.until { |lines| assert_operator lines.match_count, :>, 0 }
|
||||
tmux.send_keys 'subdir'
|
||||
tmux.until { |lines| assert_equal 1, lines.match_count }
|
||||
tmux.send_keys :Enter
|
||||
tmux.prepare
|
||||
tmux.send_keys :pwd, :Enter
|
||||
tmux.until { |lines| assert_equal "#{base}/link/subdir", lines[-1] }
|
||||
ensure
|
||||
FileUtils.rm_rf(base)
|
||||
end
|
||||
|
||||
def test_alt_c_absolute_cmd
|
||||
base = '/tmp/fzf-test-alt-c-absolute'
|
||||
FileUtils.rm_rf(base)
|
||||
FileUtils.mkdir_p(base)
|
||||
|
||||
set_var('FZF_ALT_C_COMMAND', "echo #{base}")
|
||||
|
||||
tmux.prepare
|
||||
tmux.send_keys 'cd /tmp', :Enter
|
||||
tmux.prepare
|
||||
tmux.send_keys :Escape, :c
|
||||
tmux.until { |lines| assert_equal 1, lines.match_count }
|
||||
tmux.send_keys :Enter
|
||||
tmux.prepare
|
||||
tmux.send_keys :pwd, :Enter
|
||||
tmux.until { |lines| assert_equal base, lines[-1] }
|
||||
ensure
|
||||
FileUtils.rm_rf(base)
|
||||
end
|
||||
|
||||
def test_ctrl_r
|
||||
tmux.prepare
|
||||
tmux.send_keys 'echo 1st', :Enter
|
||||
@@ -1103,3 +1144,123 @@ class TestFish < TestBase
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class TestNushell < TestBase
|
||||
include TestShell
|
||||
|
||||
def teardown
|
||||
@tmux&.kill
|
||||
end
|
||||
|
||||
def shell
|
||||
:nushell
|
||||
end
|
||||
|
||||
def set_var(name, val)
|
||||
tmux.prepare
|
||||
tmux.send_keys "$env.#{name} = '#{val}'", :Enter
|
||||
tmux.prepare
|
||||
end
|
||||
|
||||
def unset_var(name)
|
||||
tmux.prepare
|
||||
tmux.send_keys "hide-env -i #{name}", :Enter
|
||||
tmux.prepare
|
||||
end
|
||||
|
||||
def new_shell
|
||||
tmux.send_keys 'FZF_TMUX=1 nu', :Enter
|
||||
tmux.prepare
|
||||
end
|
||||
|
||||
# Override: Nushell's builtin `echo` outputs structured data, so we need
|
||||
# `^echo` (external echo) for plain text output on the command line.
|
||||
def test_ctrl_t_unicode
|
||||
writelines(['fzf-unicode 테스트1', 'fzf-unicode 테스트2'])
|
||||
set_var('FZF_CTRL_T_COMMAND', "cat #{tempname}")
|
||||
|
||||
tmux.prepare
|
||||
tmux.send_keys '^echo ', 'C-t'
|
||||
tmux.until { |lines| assert_equal 2, lines.match_count }
|
||||
tmux.send_keys 'fzf-unicode'
|
||||
tmux.until { |lines| assert_equal 2, lines.match_count }
|
||||
|
||||
tmux.send_keys '1'
|
||||
tmux.until { |lines| assert_equal 1, lines.match_count }
|
||||
tmux.send_keys :Tab
|
||||
tmux.until { |lines| assert_equal 1, lines.select_count }
|
||||
|
||||
tmux.send_keys :BSpace
|
||||
tmux.until { |lines| assert_equal 2, lines.match_count }
|
||||
|
||||
tmux.send_keys '2'
|
||||
tmux.until { |lines| assert_equal 1, lines.match_count }
|
||||
tmux.send_keys :Tab
|
||||
tmux.until { |lines| assert_equal 2, lines.select_count }
|
||||
|
||||
tmux.send_keys :Enter
|
||||
tmux.until { |lines| assert_match(/\^echo .*fzf-unicode.*1.* .*fzf-unicode.*2/, lines.join) }
|
||||
tmux.send_keys :Enter
|
||||
tmux.until { |lines| assert_equal 'fzf-unicode 테스트1 fzf-unicode 테스트2', lines[-1] }
|
||||
end
|
||||
|
||||
# Override: Nushell's external completer replaces the entire token,
|
||||
# so we use assert_includes instead of assert_equal for the result.
|
||||
# ~USERNAME expansion and backslash-escaped spaces are not applicable.
|
||||
def test_file_completion
|
||||
FileUtils.mkdir_p('/tmp/fzf-test')
|
||||
(1..100).each { |i| FileUtils.touch("/tmp/fzf-test/#{i}") }
|
||||
tmux.prepare
|
||||
|
||||
# Multi-selection
|
||||
tmux.send_keys "cat /tmp/fzf-test/10#{trigger}", :Tab
|
||||
tmux.until { |lines| assert_equal 2, lines.match_count }
|
||||
tmux.send_keys :Tab, :Tab
|
||||
tmux.until { |lines| assert_equal 2, lines.select_count }
|
||||
tmux.send_keys :Enter
|
||||
tmux.until(true) do |lines|
|
||||
assert_includes lines[-1].to_s, '/tmp/fzf-test/10'
|
||||
assert_includes lines[-1].to_s, '/tmp/fzf-test/100'
|
||||
end
|
||||
|
||||
# Single selection
|
||||
tmux.prepare
|
||||
tmux.send_keys "cat /tmp/fzf-test/10#{trigger}", :Tab
|
||||
tmux.until { |lines| assert_equal 2, lines.match_count }
|
||||
tmux.send_keys '0'
|
||||
tmux.until { |lines| assert_equal 1, lines.match_count }
|
||||
tmux.send_keys :Enter
|
||||
tmux.until(true) do |lines|
|
||||
assert_includes lines[-1].to_s, '/tmp/fzf-test/100'
|
||||
end
|
||||
|
||||
# Should include hidden files
|
||||
(1..100).each { |i| FileUtils.touch("/tmp/fzf-test/.hidden-#{i}") }
|
||||
tmux.prepare
|
||||
tmux.send_keys "cat /tmp/fzf-test/hidden#{trigger}", :Tab
|
||||
tmux.until(true) do |lines|
|
||||
assert_equal 100, lines.match_count
|
||||
assert lines.any_include?('/tmp/fzf-test/.hidden-')
|
||||
end
|
||||
tmux.send_keys :Enter
|
||||
ensure
|
||||
FileUtils.rm_rf('/tmp/fzf-test')
|
||||
end
|
||||
|
||||
# Nushell does not support multiline command recall the same way
|
||||
# as bash/zsh/fish, so test_ctrl_r_multiline is omitted.
|
||||
|
||||
# Override: only test with 'foo' -- single and double quotes cause
|
||||
# issues in Nushell's line editor.
|
||||
def test_ctrl_r_abort
|
||||
%w[foo].each do |query|
|
||||
tmux.prepare
|
||||
tmux.send_keys :Enter, query
|
||||
tmux.until { |lines| assert lines[-1]&.start_with?(query) }
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until { |lines| assert_equal "> #{query}", lines[-1] }
|
||||
tmux.send_keys 'C-g'
|
||||
tmux.until { |lines| assert lines[-1]&.start_with?(query) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,6 +6,7 @@ enew = "enew"
|
||||
tabe = "tabe"
|
||||
Iterm = "Iterm"
|
||||
ser = "ser"
|
||||
Slq = "Slq"
|
||||
|
||||
[files]
|
||||
extend-exclude = ["README.md", "*.s"]
|
||||
|
||||
@@ -114,6 +114,13 @@ if [ -d "${fish_dir}/functions" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
if command -v nu > /dev/null; then
|
||||
nushell_autoload_dir=$(nu -c '$nu.user-autoload-dirs | first')
|
||||
else
|
||||
nushell_autoload_dir=${XDG_CONFIG_HOME:-$HOME/.config}/nushell/autoload
|
||||
fi
|
||||
remove "${nushell_autoload_dir}/_fzf_integration.nu"
|
||||
|
||||
config_dir=$(dirname "$prefix_expand")
|
||||
if [[ $xdg == 1 ]] && [[ $config_dir == */fzf ]] && [[ -d $config_dir ]]; then
|
||||
rmdir "$config_dir"
|
||||
|
||||
Reference in New Issue
Block a user