Compare commits

...

57 Commits

Author SHA1 Message Date
Junegunn Choi b908f7a0ec 0.68.0 2026-02-20 11:03:46 +09:00
Kyle Tse 1a50a3c082 Fix FZF_COMPLETION_{DIR,PATH}_OPTS ignored with custom compgen functions (#4679)
When users define custom _fzf_compgen_path or _fzf_compgen_dir functions,
FZF_COMPLETION_PATH_OPTS and FZF_COMPLETION_DIR_OPTS were not applied
because the options were only computed inside the walker fallback branch.

Close #4592
2026-02-20 10:59:43 +09:00
Junegunn Choi fefea8d885 zsh: Make _fzf_compgen_{path,dir} respect FZF_COMPLETION_{PATH,DIR}_OPTS
Authored by: @LangLangBart

Fix #4592
2026-02-20 10:31:46 +09:00
bitraid 385cccd362 fish: Command history improvements (#4672)
This change provides the following improvements:

- Changes the view of the command history list, so that the script no longer depends on perl for performance.
- Enables syntax color highlighting on fish v4.3.3 and newer.
- Provides a preview window with the selected commands, and expanded view of the highlighted command if available.
- Improves the delete functionality, by successfully handling very large numbers of selected commands.
- Inserts commands in their formatted form with `<Alt-Enter>`.

---

* fish: Change history list view

The view of the command history is changed, so that no manipulation is
performed on the output of history command: The history item count
number is replaced by the Unix time of the command, multi-line display
is disabled and line wrapping is enabled by default. On fish v4.3.3
and newer, the use of ANSI color codes for syntax highlighting is now
possible, while the script no longer depends on perl for performance.

Fixes #4661

* fish: Reformat selected history commands with ALT-ENTER

* Add $FZF_WRAP environment variable

The variable is set when line wrapping is enabled. It has the value
`word` if word wrapping mode is set, otherwise it has the value `char`.

* fish: Add command history preview

The preview shows the highlighted command and any selected commands,
after first being formatted and colorized by fish_indent. The timestamp
of the highlighted command in the preview is converted to strftime
format "%F %a %T" if the date command is available.

The preview is hidden on start, and is displayed if more than 100
columns are available and one of the following conditions are met:
- The highlighted item doesn't completely fit in list view (line
wrapping is enabled for the preview and is now disabled for the list).
- The highlighted item contains newlines (multi-line commands or
strings).
- The highlighted item contains chained commands in a single line, that
can be broken down by the formatter for cleaner view.
- One or more commands are marked as selected.

* fish: Handle deletion of large number of selected history entries

* fish: Change wrapping options for the preview-window
2026-02-19 23:41:26 +09:00
Junegunn Choi 4a684b6c78 Fix test failure 2026-02-19 21:37:19 +09:00
Junegunn Choi 4a195e6323 Add --preview-wrap-sign 2026-02-19 21:30:23 +09:00
Junegunn Choi 0ecbf3f475 Fix missing wrap sign at ANSI color boundary 2026-02-19 19:49:13 +09:00
Junegunn Choi 4522868fc0 Year 2026 2026-02-19 00:20:09 +09:00
Junegunn Choi 111a62f1ea Update bash fzf option completion 2026-02-19 00:15:08 +09:00
Junegunn Choi 33cac3f0e7 Fix test case 2026-02-18 23:00:33 +09:00
Junegunn Choi 74e98cac5c Fix --preview-window follow not working correctly with wrapping (contd.) 2026-02-18 21:55:28 +09:00
Junegunn Choi c338df02c4 Fix --preview-window follow not working correctly with wrapping
Fix #3243
Fix #4258
2026-02-18 21:36:35 +09:00
Junegunn Choi 69e9abdab4 Implement word wrapping in the list section 2026-02-18 15:20:56 +09:00
Junegunn Choi b6411beaa1 Implement word wrapping in the preview window
Example:
  fzf --preview 'bat --style=plain --color=always {}' \
      --preview-window wrap-word \
      --bind space:toggle-preview-wrap-word

Close https://github.com/junegunn/fzf/discussions/3383
2026-02-18 13:35:02 +09:00
Junegunn Choi b56d614ba2 Add underline style variants and underline color support
Support double, curly, dotted, and dashed underline styles via --color
(e.g. underline-curly) and ANSI passthrough (SGR 4:N, 58, 59) with --ansi.

Close #4633
Close #4678

Thanks to @shtse8 for the test cases.
2026-02-15 01:06:46 +09:00
Junegunn Choi 49ab253555 Run sponsors workflow at midnight KST on Sundays 2026-02-15 01:00:42 +09:00
Junegunn Choi 0e859a18ed Update README 2026-02-09 09:43:47 +09:00
Sam Killen 880dd20b18 Make symlinks to directories to return as directories, not files (#4676)
Fix #4675
2026-02-08 12:57:45 +09:00
junegunn 91aa25c863 Deploying to master from @ junegunn/fzf@7e62b34087 🚀 2026-02-08 00:06:19 +00:00
LM 7e62b34087 Add fish completion support (#4605) 2026-02-05 12:50:26 +09:00
Stephen Bolton f9f0014c16 Fix bug in advanced guide for using fzf as a ripgrep launcher (#4670)
With the previous version if you passed in an initial search term that
resulted in 0 results you would get rg error output in the prompt. It
looks like down the change path the `|| true` clause was added but it
also needs to be on the initial query.
2026-02-04 17:14:02 +09:00
Junegunn Choi ab3b9fef52 Update README 2026-02-02 09:40:38 +09:00
LangLangBart 7d9724157c fix(terminal): handle SIGHUP signal (#4668) 2026-02-01 19:51:49 +09:00
Junegunn Choi bc8967632b Fix preview process not killed on exit
Thanks to @LangLangBart for the investigation and the suggested fix.

Fix #4667
2026-02-01 11:32:40 +09:00
Junegunn Choi 6360c9261c Fix coloring of items with zero-width characters
This commit fixes incorrect coloring for items that contain zero-width
characters. It also makes ellipsis coloring consistent when text is
trimmed from either the left or the right.

Fix #4620
Close #4646
2026-02-01 11:08:23 +09:00
Junegunn Choi e653628458 lint: test code 2026-02-01 11:08:23 +09:00
junegunn 28747e5cb6 Deploying to master from @ junegunn/fzf@9725eac314 🚀 2026-02-01 00:03:06 +00:00
Rahul Narsingipyta 9725eac314 fzf-tmux: Use mktemp, and fix TERM quoting (#4664) 2026-01-28 00:39:05 +09:00
Junegunn Choi b389616030 Fix track-current unset after a combined movement action
Fix #4649
Close #4663
2026-01-26 22:00:30 +09:00
junegunn 25b2248f11 Deploying to master from @ junegunn/fzf@14564e4fc7 🚀 2026-01-25 00:02:24 +00:00
Junegunn Choi 14564e4fc7 Avoid clearing the rest of the current line on start
Fix #4652

Code was reorganized so that it doesn't delete the current line if the
cursor is not at the front. This should be acceptable in almost all
cases.

Thanks to @phanen for reporting the issue and suggesting the fix.
2026-01-22 12:23:29 +09:00
junegunn d01eaa9de3 Deploying to master from @ junegunn/fzf@aad805d0d3 🚀 2026-01-18 00:02:25 +00:00
Junegunn Choi aad805d0d3 Fix x-api-key header not required for GET requests
Fix #4627
2026-01-17 15:13:57 +09:00
junegunn d1f037059a Deploying to master from @ junegunn/fzf@3f94bcb5bf 🚀 2026-01-11 00:02:24 +00:00
Junegunn Choi 3f94bcb5bf Cancel key reading when 'execute' triggered via a server request (#4653)
Fix #4524
Close #4648
2026-01-09 00:29:40 +09:00
junegunn 3c7cbc9d47 Deploying to master from @ junegunn/fzf@28e2a067e5 🚀 2026-01-04 00:02:18 +00:00
James Lazo (jazo) 28e2a067e5 fix: rebind readline command redraw-current-line (#4635)
Close #4634
2026-01-03 01:44:49 +09:00
junegunn b29e2ee2d1 Deploying to master from @ junegunn/fzf@029b241dbb 🚀 2025-12-28 00:02:17 +00:00
mrclmr 029b241dbb Fix goreleaser deprecation warnings (#4644)
Current goreleaser v2.13.1 has several deprecations:
* since v2.2: https://goreleaser.com/deprecations#snapshotname_template
* since v2.6: https://goreleaser.com/deprecations#archivesformat
* since v2.6: https://goreleaser.com/deprecations#archivesformat_overridesformat
* since v2.8: https://goreleaser.com/deprecations#archivesbuilds

Check build output locally: `goreleaser release --clean --snapshot`
2025-12-24 08:57:43 +09:00
Marcel Meyer d6ded42026 Replace nested max calls with single max 2025-12-23 09:14:33 +09:00
Marcel Meyer 6eb4b41e34 Add generic utils constraint function 2025-12-23 09:14:33 +09:00
Marcel Meyer 14b5e1d88c Replace utils Min, Max with builtin min, max 2025-12-23 09:14:33 +09:00
junegunn 603240122e Deploying to master from @ junegunn/fzf@8d688521fe 🚀 2025-12-21 00:02:22 +00:00
Charalambos Emmanouilidis 8d688521fe Fix --accept-nth being ignored in filter mode (#4636)
The --accept-nth option was not being respected when using --filter mode.
This caused fzf to output entire lines instead of only the specified fields.

Added buildItemTransformer() helper function to consistently apply field
transformations across filter mode (both streaming and non-streaming) and
select1/exit0 modes.

Fixes #4615
2025-12-19 18:31:39 +09:00
Jean-Yves LENHOF 775129367a docs(README): add mise alternative method installation (#4637)
Signed-off-by: jylenhof <jygithub@lenhof.eu.org>
2025-12-18 09:59:11 +09:00
junegunn b3b221854b Deploying to master from @ junegunn/fzf@c8cf0992c1 🚀 2025-12-14 00:02:25 +00:00
LangLangBart c8cf0992c1 zsh foreign test (#4622)
* test(zsh): add test for C-r with foreign commands

* ci: make docker command configurable via variable

Allows using alternative container runtimes, e.g., DOCKER=podman make docker-test

* test(zsh): use unique histfile for foreign commands test

* test(zsh): use multi select in foreign test
2025-12-08 11:15:44 +09:00
junegunn 33d8d51c8a Deploying to master from @ junegunn/fzf@b473477c22 🚀 2025-12-07 00:02:17 +00:00
Junegunn Choi b473477c22 Update README 2025-12-04 08:36:49 +09:00
LangLangBart fcc4178bca Updates man page documentation (#4607)
* docs(man): Add example for {+f} in preview command
* docs(man): replace echo -e with printf to improve portability across different shells
2025-12-02 18:29:46 +09:00
LangLangBart cfc37caabc feat(zsh): Handle multi-line history selection (#4595) 2025-11-30 21:32:55 +09:00
Junegunn Choi af2a81dc02 Add Goods code 2025-11-29 14:39:43 +09:00
Junegunn Choi be5a687281 Goods 2025-11-29 13:59:43 +09:00
RT 771e35b972 feat: add alt-gutter color option (#4602)
* Add alt-gutter color option

* Simplify the code

---------

Co-authored-by: Junegunn Choi <junegunn.c@gmail.com>
2025-11-29 10:43:13 +09:00
Junegunn Choi 60a5be1e65 Do not allow very long queries in FuzzyMatchV2
Close #4608
2025-11-28 18:41:45 +09:00
junegunn 1d5e87f5e4 Deploying to master from @ junegunn/fzf@3db63f5e52 🚀 2025-11-23 00:02:24 +00:00
LangLangBart 3db63f5e52 fix(terminal): correct display width calculation with maxWidth (#4596)
fix #4593

* test(core): add test for --freeze-right with long ellipsis
2025-11-20 09:09:36 +09:00
62 changed files with 3395 additions and 1153 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ name: Generate Sponsors README
on: on:
workflow_dispatch: workflow_dispatch:
schedule: schedule:
- cron: 0 0 * * 0 - cron: 0 15 * * 6
jobs: jobs:
deploy: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+6 -4
View File
@@ -85,12 +85,14 @@ notarize:
archives: archives:
- name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}" - name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
builds: ids:
- fzf - fzf
format: tar.gz formats:
- tar.gz
format_overrides: format_overrides:
- goos: windows - goos: windows
format: zip formats:
- zip
files: files:
- non-existent* - non-existent*
@@ -102,7 +104,7 @@ release:
name_template: '{{ .Version }}' name_template: '{{ .Version }}'
snapshot: snapshot:
name_template: "{{ .Version }}-devel" version_template: "{{ .Version }}-devel"
changelog: changelog:
sort: asc sort: asc
+1 -1
View File
@@ -363,7 +363,7 @@ projects, and it will free up memory as you narrow down the results.
RG_PREFIX="rg --column --line-number --no-heading --color=always --smart-case " RG_PREFIX="rg --column --line-number --no-heading --color=always --smart-case "
INITIAL_QUERY="${*:-}" INITIAL_QUERY="${*:-}"
fzf --ansi --disabled --query "$INITIAL_QUERY" \ fzf --ansi --disabled --query "$INITIAL_QUERY" \
--bind "start:reload:$RG_PREFIX {q}" \ --bind "start:reload:$RG_PREFIX {q} || true" \
--bind "change:reload:sleep 0.1; $RG_PREFIX {q} || true" \ --bind "change:reload:sleep 0.1; $RG_PREFIX {q} || true" \
--delimiter : \ --delimiter : \
--preview 'bat --color=always {1} --highlight-line {2}' \ --preview 'bat --color=always {1} --highlight-line {2}' \
+55
View File
@@ -1,6 +1,61 @@
CHANGELOG CHANGELOG
========= =========
0.68.0
------
- Implemented word wrapping in the list section
- Added `--wrap=word` (or `--wrap-word`) option and `toggle-wrap-word` action for word-level line wrapping in the list section
- Changed default binding of `ctrl-/` and `alt-/` from `toggle-wrap` to `toggle-wrap-word`
```sh
fzf --wrap=word
```
- Implemented word wrapping in the preview window
- Added `wrap-word` flag for `--preview-window` to enable word-level wrapping
- Added `toggle-preview-wrap-word` action
```sh
fzf --preview 'bat --style=plain --color=always {}' \
--preview-window wrap-word \
--bind space:toggle-preview-wrap-word
```
- Added support for underline style variants in `--color`: `underline-double`, `underline-curly`, `underline-dotted`, `underline-dashed`
```sh
fzf --color 'fg:underline-curly,current-fg:underline-dashed'
```
- Added support for underline styles (`4:N`) and underline colors (SGR 58/59)
```sh
# In the list section
printf '\e[4:3;58;2;255;0;0mRed curly underline\e[0m\n' | fzf --ansi
# In the preview window
fzf --preview "printf '\e[4:3;58;2;255;0;0mRed curly underline\e[0m\n'"
```
- Added `--preview-wrap-sign` to set a different wrap indicator for the preview window
- Added `alt-gutter` color option (#4602) (@hedgieinsocks)
- Added `$FZF_WRAP` environment variable to child processes (`char` or `word` when wrapping is enabled) (#4672) (@bitraid)
- fish: Improved command history (CTRL-R) (#4672) (@bitraid)
- Enabled syntax highlighting in the list on fish 4.3.3+
- Added syntax-highlighted preview window that auto-shows for long or multi-line commands
- Added `ALT-ENTER` to reformat and insert selected commands
- Improved handling of bulk deletion of selected history entries (`SHIFT-DELETE`)
- Added fish completion support (#4605) (@lalvarezt)
- zsh: Handle multi-line history selection (#4595) (@LangLangBart)
- Bug fixes
- Fixed `_fzf_compgen_{path,dir}` to respect `FZF_COMPLETION_{PATH,DIR}_OPTS` (#4592) (@shtse8, @LangLangBart)
- Fixed `--preview-window follow` not working correctly with wrapping (#3243, #4258)
- Fixed symlinks to directories being returned as files (#4676) (@skk64)
- Fixed SIGHUP signal handling (#4668) (@LangLangBart)
- Fixed preview process not killed on exit (#4667)
- Fixed coloring of items with zero-width characters (#4620)
- Fixed `track-current` unset after a combined movement action (#4649)
- Fixed `--accept-nth` being ignored in filter mode (#4636) (@charemma)
- Fixed display width calculation with `maxWidth` (#4596) (@LangLangBart)
- Fixed clearing of the rest of the current line on start (#4652)
- Fixed `x-api-key` header not required for GET requests (#4627)
- Fixed key reading not cancelled when `execute` triggered via a server request (#4653)
- Fixed rebind of readline command `redraw-current-line` (#4635) (@jameslazo)
- Fixed `fzf-tmux` `TERM` quoting and added `mktemp` usage (#4664) (@Goofygiraffe06)
- Do not allow very long queries in `FuzzyMatchV2` (#4608)
0.67.0 0.67.0
------ ------
- Added `--freeze-left=N` option to keep the leftmost N columns always visible. - Added `--freeze-left=N` option to keep the leftmost N columns always visible.
+1 -1
View File
@@ -1,6 +1,6 @@
The MIT License (MIT) The MIT License (MIT)
Copyright (c) 2013-2025 Junegunn Choi Copyright (c) 2013-2026 Junegunn Choi
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+5 -4
View File
@@ -1,4 +1,5 @@
GO ?= go GO ?= go
DOCKER ?= docker
GOOS ?= $(shell $(GO) env GOOS) GOOS ?= $(shell $(GO) env GOOS)
MAKEFILE := $(realpath $(lastword $(MAKEFILE_LIST))) MAKEFILE := $(realpath $(lastword $(MAKEFILE_LIST)))
@@ -192,12 +193,12 @@ bin/fzf: target/$(BINARY) | bin
cp -f target/$(BINARY) bin/fzf cp -f target/$(BINARY) bin/fzf
docker: docker:
docker build -t fzf-ubuntu . $(DOCKER) build -t fzf-ubuntu .
docker run -it fzf-ubuntu tmux $(DOCKER) run -it fzf-ubuntu tmux
docker-test: docker-test:
docker build -t fzf-ubuntu . $(DOCKER) build -t fzf-ubuntu .
docker run -it fzf-ubuntu $(DOCKER) run -it fzf-ubuntu
update: update:
$(GO) get -u $(GO) get -u
+1 -1
View File
@@ -493,4 +493,4 @@ autocmd FileType fzf set laststatus=0 noshowmode noruler
The MIT License (MIT) The MIT License (MIT)
Copyright (c) 2013-2025 Junegunn Choi Copyright (c) 2013-2026 Junegunn Choi
+99 -41
View File
File diff suppressed because one or more lines are too long
+7 -8
View File
@@ -159,11 +159,11 @@ fi
set -e set -e
# Clean up named pipes on exit # Clean up named pipes on exit
id=$RANDOM tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/fzf-tmux-XXXXXX")
argsf="${TMPDIR:-/tmp}/fzf-args-$id" argsf="$tmpdir/args"
fifo1="${TMPDIR:-/tmp}/fzf-fifo1-$id" fifo1="$tmpdir/fifo1"
fifo2="${TMPDIR:-/tmp}/fzf-fifo2-$id" fifo2="$tmpdir/fifo2"
fifo3="${TMPDIR:-/tmp}/fzf-fifo3-$id" fifo3="$tmpdir/fifo3"
if tmux_win_opts=$(tmux show-options -p remain-on-exit \; show-options -p synchronize-panes 2> /dev/null); then if tmux_win_opts=$(tmux show-options -p remain-on-exit \; show-options -p synchronize-panes 2> /dev/null); then
tmux_win_opts=($(sed '/ off/d; s/synchronize-panes/set-option -p synchronize-panes/; s/remain-on-exit/set-option -p remain-on-exit/; s/$/ \\;/' <<< "$tmux_win_opts")) tmux_win_opts=($(sed '/ off/d; s/synchronize-panes/set-option -p synchronize-panes/; s/remain-on-exit/set-option -p remain-on-exit/; s/$/ \\;/' <<< "$tmux_win_opts"))
tmux_off_opts='; set-option -p synchronize-panes off ; set-option -p remain-on-exit off' tmux_off_opts='; set-option -p synchronize-panes off ; set-option -p remain-on-exit off'
@@ -172,8 +172,7 @@ else
tmux_off_opts='; set-window-option synchronize-panes off ; set-window-option remain-on-exit off' tmux_off_opts='; set-window-option synchronize-panes off ; set-window-option remain-on-exit off'
fi fi
cleanup() { cleanup() {
\rm -f $argsf $fifo1 $fifo2 $fifo3 \rm -rf "$tmpdir"
# Restore tmux window options # Restore tmux window options
if [[ ${#tmux_win_opts[@]} -gt 1 ]]; then if [[ ${#tmux_win_opts[@]} -gt 1 ]]; then
eval "tmux ${tmux_win_opts[*]}" eval "tmux ${tmux_win_opts[*]}"
@@ -196,7 +195,7 @@ cleanup() {
trap 'cleanup 1' SIGUSR1 trap 'cleanup 1' SIGUSR1
trap 'cleanup' EXIT trap 'cleanup' EXIT
envs="export TERM=$TERM " envs="export TERM=$(printf %q "$TERM") "
if [[ $opt =~ "-E" ]]; then if [[ $opt =~ "-E" ]]; then
if [[ $tmux_version == 3.2 ]]; then if [[ $tmux_version == 3.2 ]]; then
FZF_DEFAULT_OPTS="--margin 0,1 $FZF_DEFAULT_OPTS" FZF_DEFAULT_OPTS="--margin 0,1 $FZF_DEFAULT_OPTS"
+1 -1
View File
@@ -503,7 +503,7 @@ LICENSE *fzf-license*
The MIT License (MIT) The MIT License (MIT)
Copyright (c) 2013-2025 Junegunn Choi Copyright (c) 2013-2026 Junegunn Choi
============================================================================== ==============================================================================
vim:tw=78:sw=2:ts=2:ft=help:norl:nowrap: vim:tw=78:sw=2:ts=2:ft=help:norl:nowrap:
+66 -20
View File
@@ -2,7 +2,7 @@
set -u set -u
version=0.67.0 version=0.68.0
auto_completion= auto_completion=
key_bindings= key_bindings=
update_config=2 update_config=2
@@ -243,16 +243,16 @@ fi
echo echo
for shell in $shells; do for shell in $shells; do
fzf_completion="source \"$fzf_base/shell/completion.${shell}\""
fzf_key_bindings="source \"$fzf_base/shell/key-bindings.${shell}\""
[[ $shell == fish ]] && continue [[ $shell == fish ]] && continue
src=${prefix_expand}.${shell} src=${prefix_expand}.${shell}
echo -n "Generate $src ... " echo -n "Generate $src ... "
fzf_completion="source \"$fzf_base/shell/completion.${shell}\""
if [ $auto_completion -eq 0 ]; then if [ $auto_completion -eq 0 ]; then
fzf_completion="# $fzf_completion" fzf_completion="# $fzf_completion"
fi fi
fzf_key_bindings="source \"$fzf_base/shell/key-bindings.${shell}\""
if [ $key_bindings -eq 0 ]; then if [ $key_bindings -eq 0 ]; then
fzf_key_bindings="# $fzf_key_bindings" fzf_key_bindings="# $fzf_key_bindings"
fi fi
@@ -302,15 +302,16 @@ append_line() {
line="$2" line="$2"
file="$3" file="$3"
pat="${4:-}" pat="${4:-}"
at_lno="${5:-}"
lines="" lines=""
echo "Update $file:" echo "Update $file:"
echo " - $line" echo " - $line"
if [ -f "$file" ]; then if [ -f "$file" ]; then
if [ $# -lt 4 ]; then if [[ -n $pat ]]; then
lines=$(\grep -nF "$line" "$file")
else
lines=$(\grep -nF "$pat" "$file") lines=$(\grep -nF "$pat" "$file")
else
lines=$(\grep -nF "${line#"${line%%[![:space:]]*}"}" "$file")
fi fi
fi fi
@@ -328,8 +329,12 @@ append_line() {
set -e set -e
if [ "$update" -eq 1 ]; then if [ "$update" -eq 1 ]; then
[ -f "$file" ] && echo >> "$file" if [[ -z $at_lno ]]; then
echo "$line" >> "$file" [ -f "$file" ] && echo >> "$file"
echo "$line" >> "$file"
else
sed -i.~fzf_bak "${at_lno}a\\"$'\n'"$line" "$file" && rm "$file.~fzf_bak"
fi
echo " + Added" echo " + Added"
else else
echo " ~ Skipped" echo " ~ Skipped"
@@ -362,25 +367,66 @@ for shell in $shells; do
append_line $update_config "[ -f ${prefix}.${shell} ] && source ${prefix}.${shell}" "$dest" "${prefix}.${shell}" append_line $update_config "[ -f ${prefix}.${shell} ] && source ${prefix}.${shell}" "$dest" "${prefix}.${shell}"
done done
if [ $key_bindings -eq 1 ] && [[ $shells =~ fish ]]; then if [[ $shells =~ fish ]]; then
bind_file="${fish_dir}/functions/fish_user_key_bindings.fish" bind_file="${fish_dir}/functions/fish_user_key_bindings.fish"
if [ ! -e "$bind_file" ]; then if [ ! -e "$bind_file" ]; then
mkdir -p "${fish_dir}/functions" if [[ $key_bindings -eq 1 || $auto_completion -eq 1 ]]; then
create_file "$bind_file" \ mkdir -p "${fish_dir}/functions"
'function fish_user_key_bindings' \ if [[ $key_bindings -eq 1 && $auto_completion -eq 1 ]]; then
' fzf --fish | source' \ create_file "$bind_file" \
'end' 'function fish_user_key_bindings' \
' fzf --fish | source' \
'end'
elif [[ $key_bindings -eq 1 ]]; then
create_file "$bind_file" \
'function fish_user_key_bindings' \
" $fzf_key_bindings" \
'end'
elif [[ $auto_completion -eq 1 ]]; then
create_file "$bind_file" \
'function fish_user_key_bindings' \
" $fzf_completion" \
'end'
fi
lno_func=$(\grep -nF "function fish_user_key_bindings" "$bind_file" | sed 's/:.*//' | tr '\n' ' ')
else
lno_func=0
fi
else else
echo "Check $bind_file:" echo "Check $bind_file:"
lno=$(\grep -nF "fzf_key_bindings" "$bind_file" | sed 's/:.*//' | tr '\n' ' ') lno_func=$(\grep -nF "function fish_user_key_bindings" "$bind_file" | sed 's/:.*//' | tr '\n' ' ')
if [[ -n $lno ]]; then if [[ -z $lno_func ]]; then
echo " ** Found 'fzf_key_bindings' in line #$lno" echo -e "function fish_user_key_bindings\nend" >> "$bind_file"
echo " ** You have to replace the line to 'fzf --fish | source'" lno_func=$(\grep -nF "function fish_user_key_bindings" "$bind_file" | sed 's/:.*//' | tr '\n' ' ')
fi
lno_keys=$(\grep -nF "fzf_key_bindings" "$bind_file" | sed 's/:.*//' | tr '\n' ' ')
if [[ -n $lno_keys ]]; then
echo " ** Found 'fzf_key_bindings' in line #$lno_keys"
if [[ $key_bindings -eq 1 && $auto_completion -eq 1 ]]; then
echo " ** You have to replace the line to 'fzf --fish | source'"
elif [[ $key_bindings -eq 1 ]]; then
echo " ** You have to replace the line to '$fzf_key_bindings'"
else
echo " ** You have to remove the line"
fi
echo echo
else else
echo " - Clear" echo " - Clear"
echo echo
append_line $update_config "fzf --fish | source" "$bind_file" if [[ $key_bindings -eq 1 && $auto_completion -eq 1 ]]; then
sed -i.~fzf_bak "\#$fzf_completion#d" "$bind_file" && rm "$bind_file.~fzf_bak"
sed -i.~fzf_bak "\#$fzf_key_bindings#d" "$bind_file" && rm "$bind_file.~fzf_bak"
append_line $update_config " fzf --fish | source" "$bind_file" "" "$lno_func"
else
sed -i.~fzf_bak '/fzf --fish \| source/d' "$bind_file" && rm "$bind_file.~fzf_bak"
if [[ $key_bindings -eq 1 ]]; then
sed -i.~fzf_bak "\#$fzf_completion#d" "$bind_file" && rm "$bind_file.~fzf_bak"
append_line $update_config " $fzf_key_bindings" "$bind_file" "" "$lno_func"
elif [[ $auto_completion -eq 1 ]]; then
sed -i.~fzf_bak "\#$fzf_key_bindings#d" "$bind_file" && rm "$bind_file.~fzf_bak"
append_line $update_config " $fzf_completion" "$bind_file" "" "$lno_func"
fi
fi
fi fi
fi fi
fi fi
@@ -393,7 +439,7 @@ if [ $update_config -eq 1 ]; then
echo echo
fi fi
[[ $shells =~ zsh ]] && echo " source ${ZDOTDIR:-~}/.zshrc # zsh" [[ $shells =~ zsh ]] && echo " source ${ZDOTDIR:-~}/.zshrc # zsh"
[[ $shells =~ fish ]] && [ $key_bindings -eq 1 ] && echo ' fzf_key_bindings # fish' [[ $shells =~ fish && $lno_func -ne 0 ]] && echo ' fzf_user_key_bindings # fish'
echo echo
echo 'Use uninstall script to remove fzf.' echo 'Use uninstall script to remove fzf.'
echo echo
+1 -1
View File
@@ -1,4 +1,4 @@
$version="0.67.0" $version="0.68.0"
$fzf_base=Split-Path -Parent $MyInvocation.MyCommand.Definition $fzf_base=Split-Path -Parent $MyInvocation.MyCommand.Definition
+5 -2
View File
@@ -11,7 +11,7 @@ import (
"github.com/junegunn/fzf/src/protector" "github.com/junegunn/fzf/src/protector"
) )
var version = "0.67" var version = "0.68"
var revision = "devel" var revision = "devel"
//go:embed shell/key-bindings.bash //go:embed shell/key-bindings.bash
@@ -29,6 +29,9 @@ var zshCompletion []byte
//go:embed shell/key-bindings.fish //go:embed shell/key-bindings.fish
var fishKeyBindings []byte var fishKeyBindings []byte
//go:embed shell/completion.fish
var fishCompletion []byte
//go:embed man/man1/fzf.1 //go:embed man/man1/fzf.1
var manPage []byte var manPage []byte
@@ -65,7 +68,7 @@ func main() {
} }
if options.Fish { if options.Fish {
printScript("key-bindings.fish", fishKeyBindings) printScript("key-bindings.fish", fishKeyBindings)
fmt.Println("fzf_key_bindings") printScript("completion.fish", fishCompletion)
return return
} }
if options.Help { if options.Help {
+2 -2
View File
@@ -1,7 +1,7 @@
.ig .ig
The MIT License (MIT) The MIT License (MIT)
Copyright (c) 2013-2025 Junegunn Choi Copyright (c) 2013-2026 Junegunn Choi
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@@ -21,7 +21,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE. THE SOFTWARE.
.. ..
.TH fzf\-tmux 1 "Nov 2025" "fzf 0.67.0" "fzf\-tmux - open fzf in tmux split pane" .TH fzf\-tmux 1 "Feb 2026" "fzf 0.68.0" "fzf\-tmux - open fzf in tmux split pane"
.SH NAME .SH NAME
fzf\-tmux - open fzf in tmux split pane fzf\-tmux - open fzf in tmux split pane
+32 -11
View File
@@ -1,7 +1,7 @@
.ig .ig
The MIT License (MIT) The MIT License (MIT)
Copyright (c) 2013-2025 Junegunn Choi Copyright (c) 2013-2026 Junegunn Choi
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@@ -21,7 +21,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE. THE SOFTWARE.
.. ..
.TH fzf 1 "Nov 2025" "fzf 0.67.0" "fzf - a command-line fuzzy finder" .TH fzf 1 "Feb 2026" "fzf 0.68.0" "fzf - a command-line fuzzy finder"
.SH NAME .SH NAME
fzf - a command-line fuzzy finder fzf - a command-line fuzzy finder
@@ -272,6 +272,7 @@ color mappings. Each entry is separated by a comma and/or whitespaces.
\fBgutter \fRGutter on the left \fBgutter \fRGutter on the left
\fBcurrent\-hl (hl+) \fRHighlighted substrings (current line) \fBcurrent\-hl (hl+) \fRHighlighted substrings (current line)
\fBalt\-bg \fRAlternate background color to create striped lines \fBalt\-bg \fRAlternate background color to create striped lines
\fBalt\-gutter \fRAlternate gutter color to create the striped pattern
\fBquery (input\-fg) \fRQuery string \fBquery (input\-fg) \fRQuery string
\fBghost \fRGhost text (\fB\-\-ghost\fR, \fBdim\fR applied by default) \fBghost \fRGhost text (\fB\-\-ghost\fR, \fBdim\fR applied by default)
\fBdisabled \fRQuery string when search is disabled (\fB\-\-disabled\fR) \fBdisabled \fRQuery string when search is disabled (\fB\-\-disabled\fR)
@@ -325,10 +326,14 @@ color mappings. Each entry is separated by a comma and/or whitespaces.
\fB#rrggbb \fR24-bit colors \fB#rrggbb \fR24-bit colors
.B ANSI ATTRIBUTES: (Only applies to foreground colors) .B ANSI ATTRIBUTES: (Only applies to foreground colors)
\fBregular \fRClear previously set attributes; should precede the other ones \fBregular \fRClear previously set attributes; should precede the other ones
\fBstrip \fRRemove colors \fBstrip \fRRemove colors
\fBbold\fR \fBbold\fR
\fBunderline\fR \fBunderline\fR
\fBunderline-double\fR
\fBunderline-curly\fR
\fBunderline-dotted\fR
\fBunderline-dashed\fR
\fBreverse\fR \fBreverse\fR
\fBdim\fR \fBdim\fR
\fBitalic\fR \fBitalic\fR
@@ -588,8 +593,11 @@ Highlight the whole current line
.B "\-\-cycle" .B "\-\-cycle"
Enable cyclic scroll Enable cyclic scroll
.TP .TP
.B "\-\-wrap" .BI "\-\-wrap" "[=MODE]"
Enable line wrap Enable line wrap. \fIMODE\fR can be \fBchar\fR (default) or \fBword\fR.
\fBword\fR mode wraps lines at word boundaries (spaces and tabs) instead of
at arbitrary character positions. \fB\-\-wrap\-word\fR is a synonym for
\fB\-\-wrap=word\fR.
.TP .TP
.BI "\-\-wrap\-sign" "=INDICATOR" .BI "\-\-wrap\-sign" "=INDICATOR"
Indicator for wrapped lines. The default is '↳ ' or '> ' depending on Indicator for wrapped lines. The default is '↳ ' or '> ' depending on
@@ -740,7 +748,7 @@ ENVIRONMENT VARIABLES EXPORTED TO CHILD PROCESSES.
e.g. e.g.
\fB# Prepend the current cursor position in yellow \fB# Prepend the current cursor position in yellow
fzf \-\-info\-command='echo \-e "\\x1b[33;1m$FZF_POS\\x1b[m/$FZF_INFO 💛"'\fR fzf \-\-info\-command='printf "\\x1b[33;1m$FZF_POS\\x1b[m/$FZF_INFO 💛"'\fR
.TP .TP
.B "\-\-no\-info" .B "\-\-no\-info"
@@ -840,6 +848,9 @@ e.g.
# This won't work properly without 'f' flag due to ARG_MAX limit. # This won't work properly without 'f' flag due to ARG_MAX limit.
seq 100000 | fzf \-\-preview "awk '{sum+=\\$1} END {print sum}' {*f}"\fR seq 100000 | fzf \-\-preview "awk '{sum+=\\$1} END {print sum}' {*f}"\fR
\fB# Use {+f} to get the selected items as a line-separated list
seq 100 | fzf \-\-multi \-\-bind 'enter:become:cat {+f}'\fR
Also, Also,
* \fB{q}\fR is replaced to the current query string * \fB{q}\fR is replaced to the current query string
@@ -908,6 +919,11 @@ Should be used with one of the following \fB\-\-preview\-window\fR options.
.B * border\-bottom .B * border\-bottom
.br .br
.TP
.BI "\-\-preview\-wrap\-sign" =INDICATOR
Indicator for wrapped lines in the preview window. If not set, the value of
\fB\-\-wrap\-sign\fR is used.
.TP .TP
.BI "\-\-preview\-label\-pos" [=N[:top|bottom]] .BI "\-\-preview\-label\-pos" [=N[:top|bottom]]
Position of the border label on the border line of the preview window. Specify Position of the border label on the border line of the preview window. Specify
@@ -918,7 +934,7 @@ default value 0 (or \fBcenter\fR) will put the label at the center of the
border line. border line.
.TP .TP
.BI "\-\-preview\-window=" "[POSITION][,SIZE[%]][,border\-STYLE][,[no]wrap][,[no]follow][,[no]cycle][,[no]info][,[no]hidden][,+SCROLL[OFFSETS][/DENOM]][,~HEADER_LINES][,default][,<SIZE_THRESHOLD(ALTERNATIVE_LAYOUT)]" .BI "\-\-preview\-window=" "[POSITION][,SIZE[%]][,border\-STYLE][,[no]wrap][,wrap\-word][,[no]follow][,[no]cycle][,[no]info][,[no]hidden][,+SCROLL[OFFSETS][/DENOM]][,~HEADER_LINES][,default][,<SIZE_THRESHOLD(ALTERNATIVE_LAYOUT)]"
.RS .RS
.B POSITION: (default: right) .B POSITION: (default: right)
@@ -936,7 +952,8 @@ default until \fBtoggle\-preview\fR action is triggered.
execute the command in the background. execute the command in the background.
* Long lines are truncated by default. Line wrap can be enabled with * Long lines are truncated by default. Line wrap can be enabled with
\fBwrap\fR flag. \fBwrap\fR flag. \fBwrap\-word\fR flag enables word-level wrapping, which
breaks lines at word boundaries instead of mid-word.
* Preview window will automatically scroll to the bottom when \fBfollow\fR * Preview window will automatically scroll to the bottom when \fBfollow\fR
flag is set, similarly to how \fBtail \-f\fR works. flag is set, similarly to how \fBtail \-f\fR works.
@@ -1373,6 +1390,8 @@ fzf exports the following environment variables to its child processes.
.br .br
.BR FZF_POS " Vertical position of the cursor in the list starting from 1" .BR FZF_POS " Vertical position of the cursor in the list starting from 1"
.br .br
.BR FZF_WRAP " The line wrapping mode (char, word) when enabled"
.br
.BR FZF_QUERY " Current query string" .BR FZF_QUERY " Current query string"
.br .br
.BR FZF_INPUT_STATE " Current input state (enabled, disabled, hidden)" .BR FZF_INPUT_STATE " Current input state (enabled, disabled, hidden)"
@@ -1953,12 +1972,14 @@ A key or an event can be bound to one or more of the following actions.
\fBtoggle\-multi\-line\fR \fBtoggle\-multi\-line\fR
\fBtoggle\-preview\fR \fBtoggle\-preview\fR
\fBtoggle\-preview\-wrap\fR \fBtoggle\-preview\-wrap\fR
\fBtoggle\-preview\-wrap\-word\fR
\fBtoggle\-raw\fR (toggle raw mode for displaying non-matching items) \fBtoggle\-raw\fR (toggle raw mode for displaying non-matching items)
\fBtoggle\-search\fR (toggle search functionality) \fBtoggle\-search\fR (toggle search functionality)
\fBtoggle\-sort\fR \fBtoggle\-sort\fR
\fBtoggle\-track\fR (toggle global tracking option (\fB\-\-track\fR)) \fBtoggle\-track\fR (toggle global tracking option (\fB\-\-track\fR))
\fBtoggle\-track\-current\fR (toggle tracking of the current item) \fBtoggle\-track\-current\fR (toggle tracking of the current item)
\fBtoggle\-wrap\fR \fIctrl\-/\fR \fIalt\-/\fR \fBtoggle\-wrap\fR
\fBtoggle\-wrap\-word\fR \fIctrl\-/\fR \fIalt\-/\fR
\fBtoggle+down\fR \fIctrl\-i (tab)\fR \fBtoggle+down\fR \fIctrl\-i (tab)\fR
\fBtoggle+up\fR \fIbtab (shift\-tab)\fR \fBtoggle+up\fR \fIbtab (shift\-tab)\fR
\fBtrack\-current\fR (track the current item; automatically disabled if focus changes) \fBtrack\-current\fR (track the current item; automatically disabled if focus changes)
@@ -2105,7 +2126,7 @@ payload of HTTP POST request to the \fB\-\-listen\fR server.
e.g. e.g.
\fB# Disallow selecting an empty line \fB# Disallow selecting an empty line
echo \-e "1. Hello\\n2. Goodbye\\n\\n3. Exit" | printf "1. Hello\\n2. Goodbye\\n\\n3. Exit" |
fzf \-\-height '~100%' \-\-reverse \-\-header 'Select one' \\ fzf \-\-height '~100%' \-\-reverse \-\-header 'Select one' \\
\-\-bind 'enter:transform:[[ \-n {} ]] && \-\-bind 'enter:transform:[[ \-n {} ]] &&
echo accept || echo accept ||
+1 -1
View File
@@ -1,4 +1,4 @@
" Copyright (c) 2013-2025 Junegunn Choi " Copyright (c) 2013-2026 Junegunn Choi
" "
" MIT License " MIT License
" "
+141
View File
@@ -0,0 +1,141 @@
function __fzf_defaults
# $argv[1]: Prepend to FZF_DEFAULT_OPTS_FILE and FZF_DEFAULT_OPTS
# $argv[2..]: Append to FZF_DEFAULT_OPTS_FILE and FZF_DEFAULT_OPTS
test -n "$FZF_TMUX_HEIGHT"; or set -l FZF_TMUX_HEIGHT 40%
string join ' ' -- \
"--height $FZF_TMUX_HEIGHT --min-height=20+ --bind=ctrl-z:ignore" $argv[1] \
(test -r "$FZF_DEFAULT_OPTS_FILE"; and string join -- ' ' <$FZF_DEFAULT_OPTS_FILE) \
$FZF_DEFAULT_OPTS $argv[2..-1]
end
function __fzfcmd
test -n "$FZF_TMUX_HEIGHT"; or set -l FZF_TMUX_HEIGHT 40%
if test -n "$FZF_TMUX_OPTS"
echo "fzf-tmux $FZF_TMUX_OPTS -- "
else if test "$FZF_TMUX" = "1"
echo "fzf-tmux -d$FZF_TMUX_HEIGHT -- "
else
echo "fzf"
end
end
function __fzf_cmd_tokens -d 'Return command line tokens, skipping leading env assignments and command prefixes'
# Get tokens - use version-appropriate flags
set -l tokens
if test (string match -r -- '^\d+' $version) -ge 4
set -- tokens (commandline -xpc)
else
set -- tokens (commandline -opc)
end
# Filter out leading environment variable assignments
set -l -- var_count 0
for i in $tokens
if string match -qr -- '^[\w]+=' $i
set var_count (math $var_count + 1)
else
break
end
end
set -e -- tokens[0..$var_count]
# Skip command prefixes so callers see the actual command name,
# e.g. "builtin cd" → "cd", "env VAR=1 command cd" → "cd"
while true
switch "$tokens[1]"
case builtin command
set -e -- tokens[1]
test "$tokens[1]" = "--"; and set -e -- tokens[1]
case env
set -e -- tokens[1]
test "$tokens[1]" = "--"; and set -e -- tokens[1]
while string match -qr -- '^[\w]+=' "$tokens[1]"
set -e -- tokens[1]
end
case '*'
break
end
end
string escape -n -- $tokens
end
function __fzf_parse_commandline -d 'Parse the current command line token and return split of existing filepath, fzf query, and optional -option= prefix'
set -l fzf_query ''
set -l prefix ''
set -l dir '.'
# Set variables containing the major and minor fish version numbers, using
# a method compatible with all supported fish versions.
set -l -- fish_major (string match -r -- '^\d+' $version)
set -l -- fish_minor (string match -r -- '^\d+\.(\d+)' $version)[2]
# fish v3.3.0 and newer: Don't use option prefix if " -- " is preceded.
set -l -- match_regex '(?<fzf_query>[\s\S]*?(?=\n?$)$)'
set -l -- prefix_regex '^-[^\s=]+=|^-(?!-)\S'
if test "$fish_major" -eq 3 -a "$fish_minor" -lt 3
or string match -q -v -- '* -- *' (string sub -l (commandline -Cp) -- (commandline -p))
set -- match_regex "(?<prefix>$prefix_regex)?$match_regex"
end
# Set $prefix and expanded $fzf_query with preserved trailing newlines.
if test "$fish_major" -ge 4
# fish v4.0.0 and newer
string match -q -r -- $match_regex (commandline --current-token --tokens-expanded | string collect -N)
else if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2
# fish v3.2.0 - v3.7.1 (last v3)
string match -q -r -- $match_regex (commandline --current-token --tokenize | string collect -N)
eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r -a '^\\\(?=~)|\\\(?=\$\w)' '')
else
# fish older than v3.2.0 (v3.1b1 - v3.1.2)
set -l -- cl_token (commandline --current-token --tokenize | string collect -N)
set -- prefix (string match -r -- $prefix_regex $cl_token)
set -- fzf_query (string replace -- "$prefix" '' $cl_token | string collect -N)
eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r -a '^\\\(?=~)|\\\(?=\$\w)|\\\n\\\n$' '')
end
if test -n "$fzf_query"
# Normalize path in $fzf_query, set $dir to the longest existing directory.
if test \( "$fish_major" -ge 4 \) -o \( "$fish_major" -eq 3 -a "$fish_minor" -ge 5 \)
# fish v3.5.0 and newer
set -- fzf_query (path normalize -- $fzf_query)
set -- dir $fzf_query
while not path is -d $dir
set -- dir (path dirname $dir)
end
else
# fish older than v3.5.0 (v3.1b1 - v3.4.1)
if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2
# fish v3.2.0 - v3.4.1
string match -q -r -- '(?<fzf_query>^[\s\S]*?(?=\n?$)$)' \
(string replace -r -a -- '(?<=/)/|(?<!^)/+(?!\n)$' '' $fzf_query | string collect -N)
else
# fish v3.1b1 - v3.1.2
set -- fzf_query (string replace -r -a -- '(?<=/)/|(?<!^)/+(?!\n)$' '' $fzf_query | string collect -N)
eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r '\\\n$' '')
end
set -- dir $fzf_query
while not test -d "$dir"
set -- dir (dirname -z -- "$dir" | string split0)
end
end
if not string match -q -- '.' $dir; or string match -q -r -- '^\./|^\.$' $fzf_query
# Strip $dir from $fzf_query - preserve trailing newlines.
if test "$fish_major" -ge 4
# fish v4.0.0 and newer
string match -q -r -- '^'(string escape --style=regex -- $dir)'/?(?<fzf_query>[\s\S]*)' $fzf_query
else if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2
# fish v3.2.0 - v3.7.1 (last v3)
string match -q -r -- '^/?(?<fzf_query>[\s\S]*?(?=\n?$)$)' \
(string replace -- "$dir" '' $fzf_query | string collect -N)
else
# fish older than v3.2.0 (v3.1b1 - v3.1.2)
set -- fzf_query (string replace -- "$dir" '' $fzf_query | string collect -N)
eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r -a '^/?|\\\n$' '')
end
end
end
string escape -n -- "$dir" "$fzf_query" "$prefix"
end
+61 -13
View File
@@ -124,6 +124,7 @@ _fzf_opts_completion() {
+i --no-ignore-case +i --no-ignore-case
+s --no-sort +s --no-sort
+x --no-extended +x --no-extended
--accept-nth
--ansi --ansi
--bash --bash
--bind --bind
@@ -137,56 +138,88 @@ _fzf_opts_completion() {
--expect --expect
--filepath-word --filepath-word
--fish --fish
--footer
--footer-border
--footer-label
--footer-label-pos
--freeze-left
--freeze-right
--gap
--gap-line
--ghost
--gutter
--gutter-raw
--header --header
--header-border
--header-first --header-first
--header-label
--header-label-pos
--header-lines --header-lines
--header-lines-border
--height --height
--highlight-line --highlight-line
--history --history
--history-size --history-size
--hscroll-off --hscroll-off
--info --info
--info-command
--input-border
--input-label
--input-label-pos
--jump-labels --jump-labels
--keep-right --keep-right
--layout --layout
--listen --listen
--listen-unsafe --listen-unsafe
--list-border
--list-label
--list-label-pos
--literal --literal
--man --man
--margin --margin
--marker --marker
--marker-multi-line
--min-height --min-height
--no-bold --no-bold
--no-clear
--no-hscroll --no-hscroll
--no-mouse --no-input
--no-multi-line
--no-scrollbar --no-scrollbar
--no-separator --no-separator
--no-unicode
--padding --padding
--pointer --pointer
--preview --preview
--preview-border
--preview-label --preview-label
--preview-label-pos --preview-label-pos
--preview-window --preview-window
--print-query --print-query
--print0 --print0
--prompt --prompt
--raw
--read0 --read0
--reverse
--scheme --scheme
--scroll-off --scroll-off
--scrollbar
--separator --separator
--smart-case
--style
--sync --sync
--tabstop --tabstop
--tac --tac
--tail
--tiebreak --tiebreak
--tmux --tmux
--track --track
--version --version
--walker
--walker-root
--walker-skip
--with-nth --with-nth
--with-shell --with-shell
--wrap --wrap
--wrap-sign
--preview-wrap-sign
--zsh --zsh
-0 --exit-0 -0 --exit-0
-1 --select-1 -1 --select-1
@@ -206,11 +239,11 @@ _fzf_opts_completion() {
return 0 return 0
;; ;;
--tiebreak) --tiebreak)
COMPREPLY=($(compgen -W "length chunk begin end index" -- "$cur")) COMPREPLY=($(compgen -W "length chunk pathname begin end index" -- "$cur"))
return 0 return 0
;; ;;
--color) --color)
COMPREPLY=($(compgen -W "dark light 16 bw no" -- "$cur")) COMPREPLY=($(compgen -W "dark light base16 16 bw no" -- "$cur"))
return 0 return 0
;; ;;
--layout) --layout)
@@ -221,12 +254,21 @@ _fzf_opts_completion() {
COMPREPLY=($(compgen -W "default right hidden inline inline-right" -- "$cur")) COMPREPLY=($(compgen -W "default right hidden inline inline-right" -- "$cur"))
return 0 return 0
;; ;;
--wrap)
COMPREPLY=($(compgen -W "char word" -- "$cur"))
return 0
;;
--style)
COMPREPLY=($(compgen -W "default minimal full" -- "$cur"))
return 0
;;
--preview-window) --preview-window)
COMPREPLY=($(compgen -W " COMPREPLY=($(compgen -W "
default default
hidden hidden
nohidden nohidden
wrap wrap
wrap-word
nowrap nowrap
cycle cycle
nocycle nocycle
@@ -235,6 +277,7 @@ _fzf_opts_completion() {
left left
right right
rounded border border-rounded rounded border border-rounded
border-line
sharp border-sharp sharp border-sharp
border-bold border-bold
border-block border-block
@@ -248,14 +291,16 @@ _fzf_opts_completion() {
border-left border-left
border-right border-right
follow follow
nofollow" -- "$cur")) nofollow
info
noinfo" -- "$cur"))
return 0 return 0
;; ;;
--border) --border | --list-border | --header-border | --header-lines-border | --footer-border | --input-border | --preview-border)
COMPREPLY=($(compgen -W "rounded sharp bold block thinblock double horizontal vertical top bottom left right none" -- "$cur")) COMPREPLY=($(compgen -W "line rounded sharp bold block thinblock double horizontal vertical top bottom left right none" -- "$cur"))
return 0 return 0
;; ;;
--border-label-pos | --preview-label-pos) --border-label-pos | --preview-label-pos | --list-label-pos | --header-label-pos | --footer-label-pos | --input-label-pos)
COMPREPLY=($(compgen -W "center bottom top" -- "$cur")) COMPREPLY=($(compgen -W "center bottom top" -- "$cur"))
return 0 return 0
;; ;;
@@ -325,15 +370,18 @@ __fzf_generic_path_completion() {
matches=$( matches=$(
export FZF_DEFAULT_OPTS=$(__fzf_defaults "--reverse --scheme=path" "${FZF_COMPLETION_OPTS-} $2") export FZF_DEFAULT_OPTS=$(__fzf_defaults "--reverse --scheme=path" "${FZF_COMPLETION_OPTS-} $2")
unset FZF_DEFAULT_COMMAND FZF_DEFAULT_OPTS_FILE unset FZF_DEFAULT_COMMAND FZF_DEFAULT_OPTS_FILE
if [[ $1 =~ dir ]]; then
eval "rest=(${FZF_COMPLETION_DIR_OPTS-})"
else
eval "rest=(${FZF_COMPLETION_PATH_OPTS-})"
fi
if declare -F "$1" > /dev/null; then if declare -F "$1" > /dev/null; then
eval "$1 $(printf %q "$dir")" | __fzf_comprun "$4" -q "$leftover" eval "$1 $(printf %q "$dir")" | __fzf_comprun "$4" -q "$leftover" "${rest[@]}"
else else
if [[ $1 =~ dir ]]; then if [[ $1 =~ dir ]]; then
walker=dir,follow walker=dir,follow
eval "rest=(${FZF_COMPLETION_DIR_OPTS-})"
else else
walker=file,dir,follow,hidden walker=file,dir,follow,hidden
eval "rest=(${FZF_COMPLETION_PATH_OPTS-})"
fi fi
__fzf_comprun "$4" -q "$leftover" --walker "$walker" --walker-root="$dir" "${rest[@]}" __fzf_comprun "$4" -q "$leftover" --walker "$walker" --walker-root="$dir" "${rest[@]}"
fi | while read -r item; do fi | while read -r item; do
+241
View File
@@ -0,0 +1,241 @@
# ____ ____
# / __/___ / __/
# / /_/_ / / /_
# / __/ / /_/ __/
# /_/ /___/_/ completion.fish
#
# - $FZF_COMPLETION_OPTS (default: empty)
function fzf_completion_setup
#----BEGIN INCLUDE common.fish
# NOTE: Do not directly edit this section, which is copied from "common.fish".
# To modify it, one can edit "common.fish" and run "./update.sh" to apply
# the changes. See code comments in "common.fish" for the implementation details.
function __fzf_defaults
test -n "$FZF_TMUX_HEIGHT"; or set -l FZF_TMUX_HEIGHT 40%
string join ' ' -- \
"--height $FZF_TMUX_HEIGHT --min-height=20+ --bind=ctrl-z:ignore" $argv[1] \
(test -r "$FZF_DEFAULT_OPTS_FILE"; and string join -- ' ' <$FZF_DEFAULT_OPTS_FILE) \
$FZF_DEFAULT_OPTS $argv[2..-1]
end
function __fzfcmd
test -n "$FZF_TMUX_HEIGHT"; or set -l FZF_TMUX_HEIGHT 40%
if test -n "$FZF_TMUX_OPTS"
echo "fzf-tmux $FZF_TMUX_OPTS -- "
else if test "$FZF_TMUX" = "1"
echo "fzf-tmux -d$FZF_TMUX_HEIGHT -- "
else
echo "fzf"
end
end
function __fzf_cmd_tokens -d 'Return command line tokens, skipping leading env assignments and command prefixes'
set -l tokens
if test (string match -r -- '^\d+' $version) -ge 4
set -- tokens (commandline -xpc)
else
set -- tokens (commandline -opc)
end
set -l -- var_count 0
for i in $tokens
if string match -qr -- '^[\w]+=' $i
set var_count (math $var_count + 1)
else
break
end
end
set -e -- tokens[0..$var_count]
while true
switch "$tokens[1]"
case builtin command
set -e -- tokens[1]
test "$tokens[1]" = "--"; and set -e -- tokens[1]
case env
set -e -- tokens[1]
test "$tokens[1]" = "--"; and set -e -- tokens[1]
while string match -qr -- '^[\w]+=' "$tokens[1]"
set -e -- tokens[1]
end
case '*'
break
end
end
string escape -n -- $tokens
end
function __fzf_parse_commandline -d 'Parse the current command line token and return split of existing filepath, fzf query, and optional -option= prefix'
set -l fzf_query ''
set -l prefix ''
set -l dir '.'
set -l -- fish_major (string match -r -- '^\d+' $version)
set -l -- fish_minor (string match -r -- '^\d+\.(\d+)' $version)[2]
set -l -- match_regex '(?<fzf_query>[\s\S]*?(?=\n?$)$)'
set -l -- prefix_regex '^-[^\s=]+=|^-(?!-)\S'
if test "$fish_major" -eq 3 -a "$fish_minor" -lt 3
or string match -q -v -- '* -- *' (string sub -l (commandline -Cp) -- (commandline -p))
set -- match_regex "(?<prefix>$prefix_regex)?$match_regex"
end
if test "$fish_major" -ge 4
string match -q -r -- $match_regex (commandline --current-token --tokens-expanded | string collect -N)
else if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2
string match -q -r -- $match_regex (commandline --current-token --tokenize | string collect -N)
eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r -a '^\\\(?=~)|\\\(?=\$\w)' '')
else
set -l -- cl_token (commandline --current-token --tokenize | string collect -N)
set -- prefix (string match -r -- $prefix_regex $cl_token)
set -- fzf_query (string replace -- "$prefix" '' $cl_token | string collect -N)
eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r -a '^\\\(?=~)|\\\(?=\$\w)|\\\n\\\n$' '')
end
if test -n "$fzf_query"
if test \( "$fish_major" -ge 4 \) -o \( "$fish_major" -eq 3 -a "$fish_minor" -ge 5 \)
set -- fzf_query (path normalize -- $fzf_query)
set -- dir $fzf_query
while not path is -d $dir
set -- dir (path dirname $dir)
end
else
if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2
string match -q -r -- '(?<fzf_query>^[\s\S]*?(?=\n?$)$)' \
(string replace -r -a -- '(?<=/)/|(?<!^)/+(?!\n)$' '' $fzf_query | string collect -N)
else
set -- fzf_query (string replace -r -a -- '(?<=/)/|(?<!^)/+(?!\n)$' '' $fzf_query | string collect -N)
eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r '\\\n$' '')
end
set -- dir $fzf_query
while not test -d "$dir"
set -- dir (dirname -z -- "$dir" | string split0)
end
end
if not string match -q -- '.' $dir; or string match -q -r -- '^\./|^\.$' $fzf_query
if test "$fish_major" -ge 4
string match -q -r -- '^'(string escape --style=regex -- $dir)'/?(?<fzf_query>[\s\S]*)' $fzf_query
else if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2
string match -q -r -- '^/?(?<fzf_query>[\s\S]*?(?=\n?$)$)' \
(string replace -- "$dir" '' $fzf_query | string collect -N)
else
set -- fzf_query (string replace -- "$dir" '' $fzf_query | string collect -N)
eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r -a '^/?|\\\n$' '')
end
end
end
string escape -n -- "$dir" "$fzf_query" "$prefix"
end
#----END INCLUDE
# Use complete builtin for specific commands
function __fzf_complete_native
set -l -- token (commandline -t)
set -l -- completions (eval complete -C \"$argv[1]\")
test -n "$completions"; or begin commandline -f repaint; return; end
# Calculate tabstop based on longest completion item (sample first 500 for performance)
set -l -- tabstop 20
set -l -- sample_size (math "min(500, "(count $completions)")")
for c in $completions[1..$sample_size]
set -l -- len (string length -V -- (string split -- \t $c))
test -n "$len[2]" -a "$len[1]" -gt "$tabstop"
and set -- tabstop $len[1]
end
# limit to 120 to prevent long lines
set -- tabstop (math "min($tabstop + 4, 120)")
set -l result
set -lx -- FZF_DEFAULT_OPTS (__fzf_defaults \
"--reverse --delimiter=\\t --nth=1 --tabstop=$tabstop --color=fg:dim,nth:regular" \
$FZF_COMPLETION_OPTS $argv[2..-1] --accept-nth=1 --read0 --print0)
set -- result (string join0 -- $completions | eval (__fzfcmd) | string split0)
and begin
set -l -- tail ' '
# Append / to bare ~username results (fish omits it unlike other shells)
set -- result (string replace -r -- '^(~\w+)\s?$' '$1/' $result)
# Don't add trailing space if single result is a directory
test (count $result) -eq 1
and string match -q -- '*/' "$result"; and set -- tail ''
set -l -- result (string escape -n -- $result)
string match -q -- '~*' "$token"
and set result (string replace -r -- '^\\\\~' '~' $result)
string match -q -- '$*' "$token"
and set result (string replace -r -- '^\\\\\$' '\$' $result)
commandline -rt -- (string join ' ' -- $result)$tail
end
commandline -f repaint
end
function _fzf_complete
set -l -- args (string escape -- $argv | string join ' ' | string split -- ' -- ')
set -l -- post_func (status function)_(string split -- ' ' $args[2])[1]_post
set -lx -- FZF_DEFAULT_OPTS (__fzf_defaults --reverse $FZF_COMPLETION_OPTS $args[1])
set -lx FZF_DEFAULT_OPTS_FILE
set -lx FZF_DEFAULT_COMMAND
set -l -- fzf_query (commandline -t | string escape)
set -l result
eval (__fzfcmd) --query=$fzf_query | while read -l r; set -a -- result $r; end
and if functions -q $post_func
commandline -rt -- (string collect -- $result | eval $post_func $args[2] | string join ' ')' '
else
commandline -rt -- (string join -- ' ' (string escape -- $result))' '
end
commandline -f repaint
end
# Kill completion (process selection)
function _fzf_complete_kill
set -l -- fzf_query (commandline -t | string escape)
set -lx -- FZF_DEFAULT_OPTS (__fzf_defaults --reverse $FZF_COMPLETION_OPTS \
--accept-nth=2 -m --header-lines=1 --no-preview --wrap)
set -lx FZF_DEFAULT_OPTS_FILE
if type -q ps
set -l -- ps_cmd 'begin command ps -eo user,pid,ppid,start,time,command 2>/dev/null;' \
'or command ps -eo user,pid,ppid,time,args 2>/dev/null;' \
'or command ps --everyone --full --windows 2>/dev/null; end'
set -l -- result (eval $ps_cmd \| (__fzfcmd) --query=$fzf_query)
and commandline -rt -- (string join ' ' -- $result)" "
else
__fzf_complete_native "kill " --multi --query=$fzf_query
end
commandline -f repaint
end
# Main completion function
function fzf-completion
set -l -- tokens (__fzf_cmd_tokens)
set -l -- current_token (commandline -t)
set -l -- cmd_name $tokens[1]
# Route to appropriate completion function
if test -n "$tokens"; and functions -q _fzf_complete_$cmd_name
_fzf_complete_$cmd_name $tokens
else
set -l -- fzf_opt --query=$current_token --multi
__fzf_complete_native "$tokens $current_token" $fzf_opt
end
end
# Bind Shift-Tab to fzf-completion (Tab retains native Fish behavior)
if test (string match -r -- '^\d+' $version) -ge 4
bind shift-tab fzf-completion
bind -M insert shift-tab fzf-completion
else
bind -k btab fzf-completion
bind -M insert -k btab fzf-completion
end
end
# Run setup
fzf_completion_setup
+6 -3
View File
@@ -174,15 +174,18 @@ __fzf_generic_path_completion() {
export FZF_DEFAULT_OPTS export FZF_DEFAULT_OPTS
FZF_DEFAULT_OPTS=$(__fzf_defaults "--reverse --scheme=path" "${FZF_COMPLETION_OPTS-}") FZF_DEFAULT_OPTS=$(__fzf_defaults "--reverse --scheme=path" "${FZF_COMPLETION_OPTS-}")
unset FZF_DEFAULT_COMMAND FZF_DEFAULT_OPTS_FILE unset FZF_DEFAULT_COMMAND FZF_DEFAULT_OPTS_FILE
if [[ $compgen =~ dir ]]; then
rest=${FZF_COMPLETION_DIR_OPTS-}
else
rest=${FZF_COMPLETION_PATH_OPTS-}
fi
if declare -f "$compgen" > /dev/null; then if declare -f "$compgen" > /dev/null; then
eval "$compgen $(printf %q "$dir")" | __fzf_comprun "$cmd_word" ${(Q)${(Z+n+)fzf_opts}} -q "$leftover" eval "$compgen $(printf %q "$dir")" | __fzf_comprun "$cmd_word" ${(Q)${(Z+n+)fzf_opts}} -q "$leftover" ${(Q)${(Z+n+)rest}}
else else
if [[ $compgen =~ dir ]]; then if [[ $compgen =~ dir ]]; then
walker=dir,follow walker=dir,follow
rest=${FZF_COMPLETION_DIR_OPTS-}
else else
walker=file,dir,follow,hidden walker=file,dir,follow,hidden
rest=${FZF_COMPLETION_PATH_OPTS-}
fi fi
__fzf_comprun "$cmd_word" ${(Q)${(Z+n+)fzf_opts}} -q "$leftover" --walker "$walker" --walker-root="$dir" ${(Q)${(Z+n+)rest}} < /dev/tty __fzf_comprun "$cmd_word" ${(Q)${(Z+n+)fzf_opts}} -q "$leftover" --walker "$walker" --walker-root="$dir" ${(Q)${(Z+n+)rest}} < /dev/tty
fi | while read -r item; do fi | while read -r item; do
+4 -4
View File
@@ -121,7 +121,7 @@ else # awk - fallback for POSIX systems
fi fi
# Required to refresh the prompt after fzf # Required to refresh the prompt after fzf
bind -m emacs-standard '"\er": redraw-current-line' bind -m emacs-standard '"\C-\e(": redraw-current-line'
bind -m vi-command '"\C-z": emacs-editing-mode' bind -m vi-command '"\C-z": emacs-editing-mode'
bind -m vi-insert '"\C-z": emacs-editing-mode' bind -m vi-insert '"\C-z": emacs-editing-mode'
@@ -130,7 +130,7 @@ bind -m emacs-standard '"\C-z": vi-editing-mode'
if ((BASH_VERSINFO[0] < 4)); then if ((BASH_VERSINFO[0] < 4)); then
# CTRL-T - Paste the selected file path into the command line # CTRL-T - Paste the selected file path into the command line
if [[ ${FZF_CTRL_T_COMMAND-x} != "" ]]; then if [[ ${FZF_CTRL_T_COMMAND-x} != "" ]]; then
bind -m emacs-standard '"\C-t": " \C-b\C-k \C-u`__fzf_select__`\e\C-e\er\C-a\C-y\C-h\C-e\e \C-y\ey\C-x\C-x\C-f\C-y\ey\C-_"' bind -m emacs-standard '"\C-t": " \C-b\C-k \C-u`__fzf_select__`\e\C-e\C-\e(\C-a\C-y\C-h\C-e\e \C-y\ey\C-x\C-x\C-f\C-y\ey\C-_"'
bind -m vi-command '"\C-t": "\C-z\C-t\C-z"' bind -m vi-command '"\C-t": "\C-z\C-t\C-z"'
bind -m vi-insert '"\C-t": "\C-z\C-t\C-z"' bind -m vi-insert '"\C-t": "\C-z\C-t\C-z"'
fi fi
@@ -140,7 +140,7 @@ if ((BASH_VERSINFO[0] < 4)); then
if [[ -n ${FZF_CTRL_R_COMMAND-} ]]; then if [[ -n ${FZF_CTRL_R_COMMAND-} ]]; then
echo "warning: FZF_CTRL_R_COMMAND is set to a custom command, but custom commands are not yet supported for CTRL-R" >&2 echo "warning: FZF_CTRL_R_COMMAND is set to a custom command, but custom commands are not yet supported for CTRL-R" >&2
fi fi
bind -m emacs-standard '"\C-r": "\C-e \C-u\C-y\ey\C-u`__fzf_history__`\e\C-e\er"' bind -m emacs-standard '"\C-r": "\C-e \C-u\C-y\ey\C-u`__fzf_history__`\e\C-e\C-\e("'
bind -m vi-command '"\C-r": "\C-z\C-r\C-z"' bind -m vi-command '"\C-r": "\C-z\C-r\C-z"'
bind -m vi-insert '"\C-r": "\C-z\C-r\C-z"' bind -m vi-insert '"\C-r": "\C-z\C-r\C-z"'
fi fi
@@ -165,7 +165,7 @@ fi
# ALT-C - cd into the selected directory # ALT-C - cd into the selected directory
if [[ ${FZF_ALT_C_COMMAND-x} != "" ]]; then if [[ ${FZF_ALT_C_COMMAND-x} != "" ]]; then
bind -m emacs-standard '"\ec": " \C-b\C-k \C-u`__fzf_cd__`\e\C-e\er\C-m\C-y\C-h\e \C-y\ey\C-x\C-x\C-d\C-y\ey\C-_"' bind -m emacs-standard '"\ec": " \C-b\C-k \C-u`__fzf_cd__`\e\C-e\C-\e(\C-m\C-y\C-h\e \C-y\ey\C-x\C-x\C-d\C-y\ey\C-_"'
bind -m vi-command '"\ec": "\C-z\ec\C-z"' bind -m vi-command '"\ec": "\C-z\ec\C-z"'
bind -m vi-insert '"\ec": "\C-z\ec\C-z"' bind -m vi-insert '"\ec": "\C-z\ec\C-z"'
fi fi
+88 -38
View File
@@ -21,8 +21,8 @@
function fzf_key_bindings function fzf_key_bindings
# Check fish version # Check fish version
set -l fish_ver (string match -r '^(\d+).(\d+)' $version 2> /dev/null; or echo 0\n0\n0) if set -l -- fish_ver (string match -r '^(\d+)\.(\d+)' $version 2>/dev/null)
if test \( "$fish_ver[2]" -lt 3 \) -o \( "$fish_ver[2]" -eq 3 -a "$fish_ver[3]" -lt 1 \) and test "$fish_ver[2]" -lt 3 -o "$fish_ver[2]" -eq 3 -a "$fish_ver[3]" -lt 1
echo "This script requires fish version 3.1b1 or newer." >&2 echo "This script requires fish version 3.1b1 or newer." >&2
return 1 return 1
else if not type -q fzf else if not type -q fzf
@@ -30,9 +30,12 @@ function fzf_key_bindings
return 1 return 1
end end
#----BEGIN INCLUDE common.fish
# NOTE: Do not directly edit this section, which is copied from "common.fish".
# To modify it, one can edit "common.fish" and run "./update.sh" to apply
# the changes. See code comments in "common.fish" for the implementation details.
function __fzf_defaults function __fzf_defaults
# $argv[1]: Prepend to FZF_DEFAULT_OPTS_FILE and FZF_DEFAULT_OPTS
# $argv[2..]: Append to FZF_DEFAULT_OPTS_FILE and FZF_DEFAULT_OPTS
test -n "$FZF_TMUX_HEIGHT"; or set -l FZF_TMUX_HEIGHT 40% test -n "$FZF_TMUX_HEIGHT"; or set -l FZF_TMUX_HEIGHT 40%
string join ' ' -- \ string join ' ' -- \
"--height $FZF_TMUX_HEIGHT --min-height=20+ --bind=ctrl-z:ignore" $argv[1] \ "--height $FZF_TMUX_HEIGHT --min-height=20+ --bind=ctrl-z:ignore" $argv[1] \
@@ -51,17 +54,51 @@ function fzf_key_bindings
end end
end end
function __fzf_cmd_tokens -d 'Return command line tokens, skipping leading env assignments and command prefixes'
set -l tokens
if test (string match -r -- '^\d+' $version) -ge 4
set -- tokens (commandline -xpc)
else
set -- tokens (commandline -opc)
end
set -l -- var_count 0
for i in $tokens
if string match -qr -- '^[\w]+=' $i
set var_count (math $var_count + 1)
else
break
end
end
set -e -- tokens[0..$var_count]
while true
switch "$tokens[1]"
case builtin command
set -e -- tokens[1]
test "$tokens[1]" = "--"; and set -e -- tokens[1]
case env
set -e -- tokens[1]
test "$tokens[1]" = "--"; and set -e -- tokens[1]
while string match -qr -- '^[\w]+=' "$tokens[1]"
set -e -- tokens[1]
end
case '*'
break
end
end
string escape -n -- $tokens
end
function __fzf_parse_commandline -d 'Parse the current command line token and return split of existing filepath, fzf query, and optional -option= prefix' function __fzf_parse_commandline -d 'Parse the current command line token and return split of existing filepath, fzf query, and optional -option= prefix'
set -l fzf_query '' set -l fzf_query ''
set -l prefix '' set -l prefix ''
set -l dir '.' set -l dir '.'
# Set variables containing the major and minor fish version numbers, using
# a method compatible with all supported fish versions.
set -l -- fish_major (string match -r -- '^\d+' $version) set -l -- fish_major (string match -r -- '^\d+' $version)
set -l -- fish_minor (string match -r -- '^\d+\.(\d+)' $version)[2] set -l -- fish_minor (string match -r -- '^\d+\.(\d+)' $version)[2]
# fish v3.3.0 and newer: Don't use option prefix if " -- " is preceded.
set -l -- match_regex '(?<fzf_query>[\s\S]*?(?=\n?$)$)' set -l -- match_regex '(?<fzf_query>[\s\S]*?(?=\n?$)$)'
set -l -- prefix_regex '^-[^\s=]+=|^-(?!-)\S' set -l -- prefix_regex '^-[^\s=]+=|^-(?!-)\S'
if test "$fish_major" -eq 3 -a "$fish_minor" -lt 3 if test "$fish_major" -eq 3 -a "$fish_minor" -lt 3
@@ -69,16 +106,12 @@ function fzf_key_bindings
set -- match_regex "(?<prefix>$prefix_regex)?$match_regex" set -- match_regex "(?<prefix>$prefix_regex)?$match_regex"
end end
# Set $prefix and expanded $fzf_query with preserved trailing newlines.
if test "$fish_major" -ge 4 if test "$fish_major" -ge 4
# fish v4.0.0 and newer
string match -q -r -- $match_regex (commandline --current-token --tokens-expanded | string collect -N) string match -q -r -- $match_regex (commandline --current-token --tokens-expanded | string collect -N)
else if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2 else if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2
# fish v3.2.0 - v3.7.1 (last v3)
string match -q -r -- $match_regex (commandline --current-token --tokenize | string collect -N) string match -q -r -- $match_regex (commandline --current-token --tokenize | string collect -N)
eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r -a '^\\\(?=~)|\\\(?=\$\w)' '') eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r -a '^\\\(?=~)|\\\(?=\$\w)' '')
else else
# fish older than v3.2.0 (v3.1b1 - v3.1.2)
set -l -- cl_token (commandline --current-token --tokenize | string collect -N) set -l -- cl_token (commandline --current-token --tokenize | string collect -N)
set -- prefix (string match -r -- $prefix_regex $cl_token) set -- prefix (string match -r -- $prefix_regex $cl_token)
set -- fzf_query (string replace -- "$prefix" '' $cl_token | string collect -N) set -- fzf_query (string replace -- "$prefix" '' $cl_token | string collect -N)
@@ -86,22 +119,17 @@ function fzf_key_bindings
end end
if test -n "$fzf_query" if test -n "$fzf_query"
# Normalize path in $fzf_query, set $dir to the longest existing directory.
if test \( "$fish_major" -ge 4 \) -o \( "$fish_major" -eq 3 -a "$fish_minor" -ge 5 \) if test \( "$fish_major" -ge 4 \) -o \( "$fish_major" -eq 3 -a "$fish_minor" -ge 5 \)
# fish v3.5.0 and newer
set -- fzf_query (path normalize -- $fzf_query) set -- fzf_query (path normalize -- $fzf_query)
set -- dir $fzf_query set -- dir $fzf_query
while not path is -d $dir while not path is -d $dir
set -- dir (path dirname $dir) set -- dir (path dirname $dir)
end end
else else
# fish older than v3.5.0 (v3.1b1 - v3.4.1)
if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2 if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2
# fish v3.2.0 - v3.4.1
string match -q -r -- '(?<fzf_query>^[\s\S]*?(?=\n?$)$)' \ string match -q -r -- '(?<fzf_query>^[\s\S]*?(?=\n?$)$)' \
(string replace -r -a -- '(?<=/)/|(?<!^)/+(?!\n)$' '' $fzf_query | string collect -N) (string replace -r -a -- '(?<=/)/|(?<!^)/+(?!\n)$' '' $fzf_query | string collect -N)
else else
# fish v3.1b1 - v3.1.2
set -- fzf_query (string replace -r -a -- '(?<=/)/|(?<!^)/+(?!\n)$' '' $fzf_query | string collect -N) set -- fzf_query (string replace -r -a -- '(?<=/)/|(?<!^)/+(?!\n)$' '' $fzf_query | string collect -N)
eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r '\\\n$' '') eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r '\\\n$' '')
end end
@@ -112,16 +140,12 @@ function fzf_key_bindings
end end
if not string match -q -- '.' $dir; or string match -q -r -- '^\./|^\.$' $fzf_query if not string match -q -- '.' $dir; or string match -q -r -- '^\./|^\.$' $fzf_query
# Strip $dir from $fzf_query - preserve trailing newlines.
if test "$fish_major" -ge 4 if test "$fish_major" -ge 4
# fish v4.0.0 and newer
string match -q -r -- '^'(string escape --style=regex -- $dir)'/?(?<fzf_query>[\s\S]*)' $fzf_query string match -q -r -- '^'(string escape --style=regex -- $dir)'/?(?<fzf_query>[\s\S]*)' $fzf_query
else if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2 else if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2
# fish v3.2.0 - v3.7.1 (last v3)
string match -q -r -- '^/?(?<fzf_query>[\s\S]*?(?=\n?$)$)' \ string match -q -r -- '^/?(?<fzf_query>[\s\S]*?(?=\n?$)$)' \
(string replace -- "$dir" '' $fzf_query | string collect -N) (string replace -- "$dir" '' $fzf_query | string collect -N)
else else
# fish older than v3.2.0 (v3.1b1 - v3.1.2)
set -- fzf_query (string replace -- "$dir" '' $fzf_query | string collect -N) set -- fzf_query (string replace -- "$dir" '' $fzf_query | string collect -N)
eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r -a '^/?|\\\n$' '') eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r -a '^/?|\\\n$' '')
end end
@@ -130,6 +154,7 @@ function fzf_key_bindings
string escape -n -- "$dir" "$fzf_query" "$prefix" string escape -n -- "$dir" "$fzf_query" "$prefix"
end end
#----END INCLUDE
# Store current token in $dir as root for the 'find' command # Store current token in $dir as root for the 'find' command
function fzf-file-widget -d "List files and folders" function fzf-file-widget -d "List files and folders"
@@ -140,13 +165,13 @@ function fzf_key_bindings
set -lx FZF_DEFAULT_OPTS (__fzf_defaults \ set -lx FZF_DEFAULT_OPTS (__fzf_defaults \
"--reverse --walker=file,dir,follow,hidden --scheme=path" \ "--reverse --walker=file,dir,follow,hidden --scheme=path" \
"$FZF_CTRL_T_OPTS --multi --print0") "--multi $FZF_CTRL_T_OPTS --print0")
set -lx FZF_DEFAULT_COMMAND "$FZF_CTRL_T_COMMAND" set -lx FZF_DEFAULT_COMMAND "$FZF_CTRL_T_COMMAND"
set -lx FZF_DEFAULT_OPTS_FILE set -lx FZF_DEFAULT_OPTS_FILE
set -l result (eval (__fzfcmd) --walker-root=$dir --query=$fzf_query | string split0) set -l result (eval (__fzfcmd) --walker-root=$dir --query=$fzf_query | string split0)
and commandline -rt -- (string join -- ' ' $prefix(string escape -- $result))' ' and commandline -rt -- (string join -- ' ' $prefix(string escape --no-quoted -- $result))' '
commandline -f repaint commandline -f repaint
end end
@@ -157,24 +182,46 @@ function fzf_key_bindings
set -l -- total_lines (count $command_line) set -l -- total_lines (count $command_line)
set -l -- fzf_query (string escape -- $command_line[$current_line]) set -l -- fzf_query (string escape -- $command_line[$current_line])
set -lx FZF_DEFAULT_OPTS (__fzf_defaults '' \ set -lx -- FZF_DEFAULT_OPTS (__fzf_defaults '' \
'--nth=2..,.. --scheme=history --multi --wrap-sign="\t↳ "' \ '--nth=2..,.. --scheme=history --multi --no-multi-line --no-wrap --wrap-sign="\t\t\t↳ " --preview-wrap-sign="↳ "' \
'--bind=\'shift-delete:execute-silent(eval history delete --exact --case-sensitive -- (string escape -n -- {+} | string replace -r -a "^\d*\\\\\\t|(?<=\\\\\\n)\\\\\\t" ""))+reload(eval $FZF_DEFAULT_COMMAND)\'' \ '--bind=\'shift-delete:execute-silent(for i in (string split0 -- <{+f}); eval builtin history delete --exact --case-sensitive -- (string escape -n -- $i | string replace -r "^\d*\\\\\\t" ""); end)+reload(eval $FZF_DEFAULT_COMMAND)\'' \
'--bind="alt-enter:become(string join0 -- (string collect -- {+2..} | fish_indent -i))"' \
"--bind=ctrl-r:toggle-sort,alt-r:toggle-raw --highlight-line $FZF_CTRL_R_OPTS" \ "--bind=ctrl-r:toggle-sort,alt-r:toggle-raw --highlight-line $FZF_CTRL_R_OPTS" \
'--accept-nth=2.. --read0 --print0 --with-shell='(status fish-path)\\ -c) '--accept-nth=2.. --delimiter="\t" --tabstop=4 --read0 --print0 --with-shell='(status fish-path)\\ -c)
# Add dynamic preview options if preview command isn't already set by user
if string match -qvr -- '--preview[= ]' "$FZF_DEFAULT_OPTS"
# Convert the highlighted timestamp using the date command if available
set -l -- date_cmd '{1}'
if type -q date
if date -d @0 '+%s' 2>/dev/null | string match -q 0
# GNU date
set -- date_cmd '(date -d @{1} \\"+%F %a %T\\")'
else if date -r 0 '+%s' 2>/dev/null | string match -q 0
# BSD date
set -- date_cmd '(date -r {1} \\"+%F %a %T\\")'
end
end
# Prepend the options to allow user customizations
set -p -- FZF_DEFAULT_OPTS \
'--bind="focus,resize:bg-transform:if test \\"$FZF_COLUMNS\\" -gt 100 -a \\\\( \\"$FZF_SELECT_COUNT\\" -gt 0 -o \\\\( -z \\"$FZF_WRAP\\" -a (string length -- {}) -gt (math $FZF_COLUMNS - 4) \\\\) -o (string collect -- {2..} | fish_indent | count) -gt 1 \\\\); echo show-preview; else echo hide-preview; end"' \
'--preview="string collect -- (test \\"$FZF_SELECT_COUNT\\" -gt 0; and string collect -- {+2..}) \\"\\n# \\"'$date_cmd' {2..} | fish_indent --ansi"' \
'--preview-window="right,50%,wrap-word,follow,info,hidden"'
end
set -lx FZF_DEFAULT_OPTS_FILE set -lx FZF_DEFAULT_OPTS_FILE
set -lx FZF_DEFAULT_COMMAND
if type -q perl set -lx -- FZF_DEFAULT_COMMAND 'builtin history -z --show-time="%s%t"'
set -a FZF_DEFAULT_OPTS '--tac'
set FZF_DEFAULT_COMMAND 'builtin history -z --reverse | command perl -0 -pe \'s/^/$.\t/g; s/\n/\n\t/gm\'' # Enable syntax highlighting colors on fish v4.3.3 and newer
else if set -l -- v (string match -r -- '^(\d+)\.(\d+)(?:\.(\d+))?' $version)
set FZF_DEFAULT_COMMAND \ and test "$v[2]" -gt 4 -o "$v[2]" -eq 4 -a \
'set -l h (builtin history -z --reverse | string split0);' \ \( "$v[3]" -gt 3 -o "$v[3]" -eq 3 -a \
'for i in (seq (count $h) -1 1);' \ \( -n "$v[4]" -a "$v[4]" -ge 3 \) \)
'string join0 -- $i\t(string replace -a -- \n \n\t $h[$i] | string collect);' \
'end' set -a -- FZF_DEFAULT_OPTS '--ansi'
set -a -- FZF_DEFAULT_COMMAND '--color=always'
end end
# Merge history from other sessions before searching # Merge history from other sessions before searching
@@ -182,11 +229,11 @@ function fzf_key_bindings
if set -l result (eval $FZF_DEFAULT_COMMAND \| (__fzfcmd) --query=$fzf_query | string split0) if set -l result (eval $FZF_DEFAULT_COMMAND \| (__fzfcmd) --query=$fzf_query | string split0)
if test "$total_lines" -eq 1 if test "$total_lines" -eq 1
commandline -- (string replace -a -- \n\t \n $result) commandline -- $result
else else
set -l a (math $current_line - 1) set -l a (math $current_line - 1)
set -l b (math $current_line + 1) set -l b (math $current_line + 1)
commandline -- $command_line[1..$a] (string replace -a -- \n\t \n $result) commandline -- $command_line[1..$a] $result
commandline -a -- '' $command_line[$b..-1] commandline -a -- '' $command_line[$b..-1]
end end
end end
@@ -234,3 +281,6 @@ function fzf_key_bindings
end end
end end
# Run setup
fzf_key_bindings
+33 -6
View File
@@ -128,25 +128,52 @@ fi
# CTRL-R - Paste the selected command from history into the command line # CTRL-R - Paste the selected command from history into the command line
fzf-history-widget() { fzf-history-widget() {
local selected local selected extracted_with_perl=0
setopt localoptions noglobsubst noposixbuiltins pipefail no_aliases noglob nobash_rematch 2> /dev/null setopt localoptions noglobsubst noposixbuiltins pipefail no_aliases no_glob no_ksharrays extendedglob 2> /dev/null
# Ensure the module is loaded if not already, and the required features, such # Ensure the module is loaded if not already, and the required features, such
# as the associative 'history' array, which maps event numbers to full history # as the associative 'history' array, which maps event numbers to full history
# lines, are set. Also, make sure Perl is installed for multi-line output. # lines, are set. Also, make sure Perl is installed for multi-line output.
if zmodload -F zsh/parameter p:{commands,history} 2>/dev/null && (( ${+commands[perl]} )); then if zmodload -F zsh/parameter p:{commands,history} 2>/dev/null && (( ${+commands[perl]} )); then
selected="$(printf '%s\t%s\000' "${(kv)history[@]}" | selected="$(printf '%s\t%s\000' "${(kv)history[@]}" |
perl -0 -ne 'if (!$seen{(/^\s*[0-9]+\**\t(.*)/s, $1)}++) { s/\n/\n\t/g; print; }' | perl -0 -ne 'if (!$seen{(/^\s*[0-9]+\**\t(.*)/s, $1)}++) { s/\n/\n\t/g; print; }' |
FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '\t↳ ' --highlight-line ${FZF_CTRL_R_OPTS-} --query=${(qqq)LBUFFER} +m --read0") \ FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '\t↳ ' --highlight-line --multi ${FZF_CTRL_R_OPTS-} --query=${(qqq)LBUFFER} --read0") \
FZF_DEFAULT_OPTS_FILE='' $(__fzfcmd))" FZF_DEFAULT_OPTS_FILE='' $(__fzfcmd))"
extracted_with_perl=1
else else
selected="$(fc -rl 1 | __fzf_exec_awk '{ cmd=$0; sub(/^[ \t]*[0-9]+\**[ \t]+/, "", cmd); if (!seen[cmd]++) print $0 }' | selected="$(fc -rl 1 | __fzf_exec_awk '{ cmd=$0; sub(/^[ \t]*[0-9]+\**[ \t]+/, "", cmd); if (!seen[cmd]++) print $0 }' |
FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '\t↳ ' --highlight-line ${FZF_CTRL_R_OPTS-} --query=${(qqq)LBUFFER} +m") \ FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '\t↳ ' --highlight-line --multi ${FZF_CTRL_R_OPTS-} --query=${(qqq)LBUFFER}") \
FZF_DEFAULT_OPTS_FILE='' $(__fzfcmd))" FZF_DEFAULT_OPTS_FILE='' $(__fzfcmd))"
fi fi
local ret=$? local ret=$?
local -a cmds
# Avoid leaking auto assigned values when using backreferences '(#b)'
local -a mbegin mend match
if [ -n "$selected" ]; then if [ -n "$selected" ]; then
if [[ $(__fzf_exec_awk '{print $1; exit}' <<< "$selected") =~ ^[1-9][0-9]* ]]; then # Heuristic to check if the selected value is from history or a custom query
zle vi-fetch-history -n $MATCH if ((( extracted_with_perl )) && [[ $selected == <->$'\t'* ]]) ||
((( ! extracted_with_perl )) && [[ $selected == [[:blank:]]#<->( |\* )* ]]); then
# Split at newlines
for line in ${(ps:\n:)selected}; do
if (( extracted_with_perl )); then
if [[ $line == (#b)(<->)(#B)$'\t'* ]]; then
(( ${+history[${match[1]}]} )) && cmds+=("${history[${match[1]}]}")
fi
elif [[ $line == [[:blank:]]#(#b)(<->)(#B)( |\* )* ]]; then
# Avoid $history array: lags behind 'fc' on foreign commands (*)
# https://zsh.org/mla/users/2024/msg00692.html
# Push BUFFER onto stack; fetch and save history entry from BUFFER; restore
zle .push-line
zle vi-fetch-history -n ${match[1]}
(( ${#BUFFER} )) && cmds+=("${BUFFER}")
BUFFER=""
zle .get-line
fi
done
if (( ${#cmds[@]} )); then
# Join by newline after stripping trailing newlines from each command
BUFFER="${(pj:\n:)${(@)cmds%%$'\n'#}}"
CURSOR=${#BUFFER}
fi
else # selected is a custom query, not from history else # selected is a custom query, not from history
LBUFFER="$selected" LBUFFER="$selected"
fi fi
+14 -12
View File
@@ -8,24 +8,26 @@ dir=${0%"${0##*/}"}
update() { update() {
{ {
sed -n '1,/^#----BEGIN INCLUDE common\.sh/p' "$1" sed -n "1,/^#----BEGIN INCLUDE $1/p" "$2"
cat << EOF cat << EOF
# NOTE: Do not directly edit this section, which is copied from "common.sh". # NOTE: Do not directly edit this section, which is copied from "$1".
# To modify it, one can edit "common.sh" and run "./update.sh" to apply # To modify it, one can edit "$1" and run "./update.sh" to apply
# the changes. See code comments in "common.sh" for the implementation details. # the changes. See code comments in "$1" for the implementation details.
EOF EOF
echo echo
grep -v '^[[:blank:]]*#' "$dir/common.sh" # remove code comments in common.sh grep -v '^[[:blank:]]*#' "$dir/$1" # remove code comments from the common file
sed -n '/^#----END INCLUDE/,$p' "$1" sed -n '/^#----END INCLUDE/,$p' "$2"
} > "$1.part" } > "$2.part"
mv -f "$1.part" "$1" mv -f "$2.part" "$2"
} }
update "$dir/completion.bash" update "common.sh" "$dir/completion.bash"
update "$dir/completion.zsh" update "common.sh" "$dir/completion.zsh"
update "$dir/key-bindings.bash" update "common.sh" "$dir/key-bindings.bash"
update "$dir/key-bindings.zsh" update "common.sh" "$dir/key-bindings.zsh"
update "common.fish" "$dir/completion.fish"
update "common.fish" "$dir/key-bindings.fish"
# Check if --check is in ARGV # Check if --check is in ARGV
check=0 check=0
+1 -1
View File
@@ -1,6 +1,6 @@
The MIT License (MIT) The MIT License (MIT)
Copyright (c) 2013-2025 Junegunn Choi Copyright (c) 2013-2026 Junegunn Choi
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+107 -105
View File
@@ -75,114 +75,116 @@ func _() {
_ = x[actToggleTrackCurrent-64] _ = x[actToggleTrackCurrent-64]
_ = x[actToggleHeader-65] _ = x[actToggleHeader-65]
_ = x[actToggleWrap-66] _ = x[actToggleWrap-66]
_ = x[actToggleMultiLine-67] _ = x[actToggleWrapWord-67]
_ = x[actToggleHscroll-68] _ = x[actToggleMultiLine-68]
_ = x[actToggleRaw-69] _ = x[actToggleHscroll-69]
_ = x[actEnableRaw-70] _ = x[actToggleRaw-70]
_ = x[actDisableRaw-71] _ = x[actEnableRaw-71]
_ = x[actTrackCurrent-72] _ = x[actDisableRaw-72]
_ = x[actToggleInput-73] _ = x[actTrackCurrent-73]
_ = x[actHideInput-74] _ = x[actToggleInput-74]
_ = x[actShowInput-75] _ = x[actHideInput-75]
_ = x[actUntrackCurrent-76] _ = x[actShowInput-76]
_ = x[actDown-77] _ = x[actUntrackCurrent-77]
_ = x[actDownMatch-78] _ = x[actDown-78]
_ = x[actUp-79] _ = x[actDownMatch-79]
_ = x[actUpMatch-80] _ = x[actUp-80]
_ = x[actPageUp-81] _ = x[actUpMatch-81]
_ = x[actPageDown-82] _ = x[actPageUp-82]
_ = x[actPosition-83] _ = x[actPageDown-83]
_ = x[actHalfPageUp-84] _ = x[actPosition-84]
_ = x[actHalfPageDown-85] _ = x[actHalfPageUp-85]
_ = x[actOffsetUp-86] _ = x[actHalfPageDown-86]
_ = x[actOffsetDown-87] _ = x[actOffsetUp-87]
_ = x[actOffsetMiddle-88] _ = x[actOffsetDown-88]
_ = x[actJump-89] _ = x[actOffsetMiddle-89]
_ = x[actJumpAccept-90] _ = x[actJump-90]
_ = x[actPrintQuery-91] _ = x[actJumpAccept-91]
_ = x[actRefreshPreview-92] _ = x[actPrintQuery-92]
_ = x[actReplaceQuery-93] _ = x[actRefreshPreview-93]
_ = x[actToggleSort-94] _ = x[actReplaceQuery-94]
_ = x[actShowPreview-95] _ = x[actToggleSort-95]
_ = x[actHidePreview-96] _ = x[actShowPreview-96]
_ = x[actTogglePreview-97] _ = x[actHidePreview-97]
_ = x[actTogglePreviewWrap-98] _ = x[actTogglePreview-98]
_ = x[actTransform-99] _ = x[actTogglePreviewWrap-99]
_ = x[actTransformBorderLabel-100] _ = x[actTogglePreviewWrapWord-100]
_ = x[actTransformGhost-101] _ = x[actTransform-101]
_ = x[actTransformHeader-102] _ = x[actTransformBorderLabel-102]
_ = x[actTransformFooter-103] _ = x[actTransformGhost-103]
_ = x[actTransformHeaderLabel-104] _ = x[actTransformHeader-104]
_ = x[actTransformFooterLabel-105] _ = x[actTransformFooter-105]
_ = x[actTransformInputLabel-106] _ = x[actTransformHeaderLabel-106]
_ = x[actTransformListLabel-107] _ = x[actTransformFooterLabel-107]
_ = x[actTransformNth-108] _ = x[actTransformInputLabel-108]
_ = x[actTransformPointer-109] _ = x[actTransformListLabel-109]
_ = x[actTransformPreviewLabel-110] _ = x[actTransformNth-110]
_ = x[actTransformPrompt-111] _ = x[actTransformPointer-111]
_ = x[actTransformQuery-112] _ = x[actTransformPreviewLabel-112]
_ = x[actTransformSearch-113] _ = x[actTransformPrompt-113]
_ = x[actTrigger-114] _ = x[actTransformQuery-114]
_ = x[actBgTransform-115] _ = x[actTransformSearch-115]
_ = x[actBgTransformBorderLabel-116] _ = x[actTrigger-116]
_ = x[actBgTransformGhost-117] _ = x[actBgTransform-117]
_ = x[actBgTransformHeader-118] _ = x[actBgTransformBorderLabel-118]
_ = x[actBgTransformFooter-119] _ = x[actBgTransformGhost-119]
_ = x[actBgTransformHeaderLabel-120] _ = x[actBgTransformHeader-120]
_ = x[actBgTransformFooterLabel-121] _ = x[actBgTransformFooter-121]
_ = x[actBgTransformInputLabel-122] _ = x[actBgTransformHeaderLabel-122]
_ = x[actBgTransformListLabel-123] _ = x[actBgTransformFooterLabel-123]
_ = x[actBgTransformNth-124] _ = x[actBgTransformInputLabel-124]
_ = x[actBgTransformPointer-125] _ = x[actBgTransformListLabel-125]
_ = x[actBgTransformPreviewLabel-126] _ = x[actBgTransformNth-126]
_ = x[actBgTransformPrompt-127] _ = x[actBgTransformPointer-127]
_ = x[actBgTransformQuery-128] _ = x[actBgTransformPreviewLabel-128]
_ = x[actBgTransformSearch-129] _ = x[actBgTransformPrompt-129]
_ = x[actBgCancel-130] _ = x[actBgTransformQuery-130]
_ = x[actSearch-131] _ = x[actBgTransformSearch-131]
_ = x[actPreview-132] _ = x[actBgCancel-132]
_ = x[actPreviewTop-133] _ = x[actSearch-133]
_ = x[actPreviewBottom-134] _ = x[actPreview-134]
_ = x[actPreviewUp-135] _ = x[actPreviewTop-135]
_ = x[actPreviewDown-136] _ = x[actPreviewBottom-136]
_ = x[actPreviewPageUp-137] _ = x[actPreviewUp-137]
_ = x[actPreviewPageDown-138] _ = x[actPreviewDown-138]
_ = x[actPreviewHalfPageUp-139] _ = x[actPreviewPageUp-139]
_ = x[actPreviewHalfPageDown-140] _ = x[actPreviewPageDown-140]
_ = x[actPrevHistory-141] _ = x[actPreviewHalfPageUp-141]
_ = x[actPrevSelected-142] _ = x[actPreviewHalfPageDown-142]
_ = x[actPrint-143] _ = x[actPrevHistory-143]
_ = x[actPut-144] _ = x[actPrevSelected-144]
_ = x[actNextHistory-145] _ = x[actPrint-145]
_ = x[actNextSelected-146] _ = x[actPut-146]
_ = x[actExecute-147] _ = x[actNextHistory-147]
_ = x[actExecuteSilent-148] _ = x[actNextSelected-148]
_ = x[actExecuteMulti-149] _ = x[actExecute-149]
_ = x[actSigStop-150] _ = x[actExecuteSilent-150]
_ = x[actBest-151] _ = x[actExecuteMulti-151]
_ = x[actFirst-152] _ = x[actSigStop-152]
_ = x[actLast-153] _ = x[actBest-153]
_ = x[actReload-154] _ = x[actFirst-154]
_ = x[actReloadSync-155] _ = x[actLast-155]
_ = x[actDisableSearch-156] _ = x[actReload-156]
_ = x[actEnableSearch-157] _ = x[actReloadSync-157]
_ = x[actSelect-158] _ = x[actDisableSearch-158]
_ = x[actDeselect-159] _ = x[actEnableSearch-159]
_ = x[actUnbind-160] _ = x[actSelect-160]
_ = x[actRebind-161] _ = x[actDeselect-161]
_ = x[actToggleBind-162] _ = x[actUnbind-162]
_ = x[actBecome-163] _ = x[actRebind-163]
_ = x[actShowHeader-164] _ = x[actToggleBind-164]
_ = x[actHideHeader-165] _ = x[actBecome-165]
_ = x[actBell-166] _ = x[actShowHeader-166]
_ = x[actExclude-167] _ = x[actHideHeader-167]
_ = x[actExcludeMulti-168] _ = x[actBell-168]
_ = x[actAsync-169] _ = x[actExclude-169]
_ = x[actExcludeMulti-170]
_ = x[actAsync-171]
} }
const _actionType_name = "actIgnoreactStartactClickactInvalidactBracketedPasteBeginactBracketedPasteEndactCharactMouseactBeginningOfLineactAbortactAcceptactAcceptNonEmptyactAcceptOrPrintQueryactBackwardCharactBackwardDeleteCharactBackwardDeleteCharEofactBackwardWordactBackwardSubWordactCancelactChangeBorderLabelactChangeGhostactChangeHeaderactChangeFooteractChangeHeaderLabelactChangeFooterLabelactChangeInputLabelactChangeListLabelactChangeMultiactChangeNthactChangePointeractChangePreviewactChangePreviewLabelactChangePreviewWindowactChangePromptactChangeQueryactClearScreenactClearQueryactClearSelectionactCloseactDeleteCharactDeleteCharEofactEndOfLineactFatalactForwardCharactForwardWordactForwardSubWordactKillLineactKillWordactKillSubWordactUnixLineDiscardactUnixWordRuboutactYankactBackwardKillWordactBackwardKillSubWordactSelectAllactDeselectAllactToggleactToggleSearchactToggleAllactToggleDownactToggleUpactToggleInactToggleOutactToggleTrackactToggleTrackCurrentactToggleHeaderactToggleWrapactToggleMultiLineactToggleHscrollactToggleRawactEnableRawactDisableRawactTrackCurrentactToggleInputactHideInputactShowInputactUntrackCurrentactDownactDownMatchactUpactUpMatchactPageUpactPageDownactPositionactHalfPageUpactHalfPageDownactOffsetUpactOffsetDownactOffsetMiddleactJumpactJumpAcceptactPrintQueryactRefreshPreviewactReplaceQueryactToggleSortactShowPreviewactHidePreviewactTogglePreviewactTogglePreviewWrapactTransformactTransformBorderLabelactTransformGhostactTransformHeaderactTransformFooteractTransformHeaderLabelactTransformFooterLabelactTransformInputLabelactTransformListLabelactTransformNthactTransformPointeractTransformPreviewLabelactTransformPromptactTransformQueryactTransformSearchactTriggeractBgTransformactBgTransformBorderLabelactBgTransformGhostactBgTransformHeaderactBgTransformFooteractBgTransformHeaderLabelactBgTransformFooterLabelactBgTransformInputLabelactBgTransformListLabelactBgTransformNthactBgTransformPointeractBgTransformPreviewLabelactBgTransformPromptactBgTransformQueryactBgTransformSearchactBgCancelactSearchactPreviewactPreviewTopactPreviewBottomactPreviewUpactPreviewDownactPreviewPageUpactPreviewPageDownactPreviewHalfPageUpactPreviewHalfPageDownactPrevHistoryactPrevSelectedactPrintactPutactNextHistoryactNextSelectedactExecuteactExecuteSilentactExecuteMultiactSigStopactBestactFirstactLastactReloadactReloadSyncactDisableSearchactEnableSearchactSelectactDeselectactUnbindactRebindactToggleBindactBecomeactShowHeaderactHideHeaderactBellactExcludeactExcludeMultiactAsync" const _actionType_name = "actIgnoreactStartactClickactInvalidactBracketedPasteBeginactBracketedPasteEndactCharactMouseactBeginningOfLineactAbortactAcceptactAcceptNonEmptyactAcceptOrPrintQueryactBackwardCharactBackwardDeleteCharactBackwardDeleteCharEofactBackwardWordactBackwardSubWordactCancelactChangeBorderLabelactChangeGhostactChangeHeaderactChangeFooteractChangeHeaderLabelactChangeFooterLabelactChangeInputLabelactChangeListLabelactChangeMultiactChangeNthactChangePointeractChangePreviewactChangePreviewLabelactChangePreviewWindowactChangePromptactChangeQueryactClearScreenactClearQueryactClearSelectionactCloseactDeleteCharactDeleteCharEofactEndOfLineactFatalactForwardCharactForwardWordactForwardSubWordactKillLineactKillWordactKillSubWordactUnixLineDiscardactUnixWordRuboutactYankactBackwardKillWordactBackwardKillSubWordactSelectAllactDeselectAllactToggleactToggleSearchactToggleAllactToggleDownactToggleUpactToggleInactToggleOutactToggleTrackactToggleTrackCurrentactToggleHeaderactToggleWrapactToggleWrapWordactToggleMultiLineactToggleHscrollactToggleRawactEnableRawactDisableRawactTrackCurrentactToggleInputactHideInputactShowInputactUntrackCurrentactDownactDownMatchactUpactUpMatchactPageUpactPageDownactPositionactHalfPageUpactHalfPageDownactOffsetUpactOffsetDownactOffsetMiddleactJumpactJumpAcceptactPrintQueryactRefreshPreviewactReplaceQueryactToggleSortactShowPreviewactHidePreviewactTogglePreviewactTogglePreviewWrapactTogglePreviewWrapWordactTransformactTransformBorderLabelactTransformGhostactTransformHeaderactTransformFooteractTransformHeaderLabelactTransformFooterLabelactTransformInputLabelactTransformListLabelactTransformNthactTransformPointeractTransformPreviewLabelactTransformPromptactTransformQueryactTransformSearchactTriggeractBgTransformactBgTransformBorderLabelactBgTransformGhostactBgTransformHeaderactBgTransformFooteractBgTransformHeaderLabelactBgTransformFooterLabelactBgTransformInputLabelactBgTransformListLabelactBgTransformNthactBgTransformPointeractBgTransformPreviewLabelactBgTransformPromptactBgTransformQueryactBgTransformSearchactBgCancelactSearchactPreviewactPreviewTopactPreviewBottomactPreviewUpactPreviewDownactPreviewPageUpactPreviewPageDownactPreviewHalfPageUpactPreviewHalfPageDownactPrevHistoryactPrevSelectedactPrintactPutactNextHistoryactNextSelectedactExecuteactExecuteSilentactExecuteMultiactSigStopactBestactFirstactLastactReloadactReloadSyncactDisableSearchactEnableSearchactSelectactDeselectactUnbindactRebindactToggleBindactBecomeactShowHeaderactHideHeaderactBellactExcludeactExcludeMultiactAsync"
var _actionType_index = [...]uint16{0, 9, 17, 25, 35, 57, 77, 84, 92, 110, 118, 127, 144, 165, 180, 201, 225, 240, 258, 267, 287, 301, 316, 331, 351, 371, 390, 408, 422, 434, 450, 466, 487, 509, 524, 538, 552, 565, 582, 590, 603, 619, 631, 639, 653, 667, 684, 695, 706, 720, 738, 755, 762, 781, 803, 815, 829, 838, 853, 865, 878, 889, 900, 912, 926, 947, 962, 975, 993, 1009, 1021, 1033, 1046, 1061, 1075, 1087, 1099, 1116, 1123, 1135, 1140, 1150, 1159, 1170, 1181, 1194, 1209, 1220, 1233, 1248, 1255, 1268, 1281, 1298, 1313, 1326, 1340, 1354, 1370, 1390, 1402, 1425, 1442, 1460, 1478, 1501, 1524, 1546, 1567, 1582, 1601, 1625, 1643, 1660, 1678, 1688, 1702, 1727, 1746, 1766, 1786, 1811, 1836, 1860, 1883, 1900, 1921, 1947, 1967, 1986, 2006, 2017, 2026, 2036, 2049, 2065, 2077, 2091, 2107, 2125, 2145, 2167, 2181, 2196, 2204, 2210, 2224, 2239, 2249, 2265, 2280, 2290, 2297, 2305, 2312, 2321, 2334, 2350, 2365, 2374, 2385, 2394, 2403, 2416, 2425, 2438, 2451, 2458, 2468, 2483, 2491} var _actionType_index = [...]uint16{0, 9, 17, 25, 35, 57, 77, 84, 92, 110, 118, 127, 144, 165, 180, 201, 225, 240, 258, 267, 287, 301, 316, 331, 351, 371, 390, 408, 422, 434, 450, 466, 487, 509, 524, 538, 552, 565, 582, 590, 603, 619, 631, 639, 653, 667, 684, 695, 706, 720, 738, 755, 762, 781, 803, 815, 829, 838, 853, 865, 878, 889, 900, 912, 926, 947, 962, 975, 992, 1010, 1026, 1038, 1050, 1063, 1078, 1092, 1104, 1116, 1133, 1140, 1152, 1157, 1167, 1176, 1187, 1198, 1211, 1226, 1237, 1250, 1265, 1272, 1285, 1298, 1315, 1330, 1343, 1357, 1371, 1387, 1407, 1431, 1443, 1466, 1483, 1501, 1519, 1542, 1565, 1587, 1608, 1623, 1642, 1666, 1684, 1701, 1719, 1729, 1743, 1768, 1787, 1807, 1827, 1852, 1877, 1901, 1924, 1941, 1962, 1988, 2008, 2027, 2047, 2058, 2067, 2077, 2090, 2106, 2118, 2132, 2148, 2166, 2186, 2208, 2222, 2237, 2245, 2251, 2265, 2280, 2290, 2306, 2321, 2331, 2338, 2346, 2353, 2362, 2375, 2391, 2406, 2415, 2426, 2435, 2444, 2457, 2466, 2479, 2492, 2499, 2509, 2524, 2532}
func (i actionType) String() string { func (i actionType) String() string {
if i < 0 || i >= actionType(len(_actionType_index)-1) { if i < 0 || i >= actionType(len(_actionType_index)-1) {
+9 -7
View File
@@ -445,7 +445,9 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
// Since O(nm) algorithm can be prohibitively expensive for large input, // Since O(nm) algorithm can be prohibitively expensive for large input,
// we fall back to the greedy algorithm. // we fall back to the greedy algorithm.
if slab != nil && N*M > cap(slab.I16) { // Also, we should not allow a very long pattern to avoid 16-bit integer
// overflow in the score matrix. 1000 is a safe limit.
if slab != nil && N*M > cap(slab.I16) || M > 1000 {
return FuzzyMatchV1(caseSensitive, normalize, forward, input, pattern, withPos, slab) return FuzzyMatchV1(caseSensitive, normalize, forward, input, pattern, withPos, slab)
} }
@@ -501,7 +503,7 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
if pidx < M { if pidx < M {
F[pidx] = int32(off) F[pidx] = int32(off)
pidx++ pidx++
pchar = pattern[util.Min(pidx, M-1)] pchar = pattern[min(pidx, M-1)]
} }
lastIdx = off lastIdx = off
} }
@@ -519,9 +521,9 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
inGap = false inGap = false
} else { } else {
if inGap { if inGap {
H0[off] = util.Max16(prevH0+scoreGapExtension, 0) H0[off] = max(prevH0+scoreGapExtension, 0)
} else { } else {
H0[off] = util.Max16(prevH0+scoreGapStart, 0) H0[off] = max(prevH0+scoreGapStart, 0)
} }
C0[off] = 0 C0[off] = 0
inGap = true inGap = true
@@ -587,7 +589,7 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
if b >= bonusBoundary && b > fb { if b >= bonusBoundary && b > fb {
consecutive = 1 consecutive = 1
} else { } else {
b = util.Max16(b, util.Max16(bonusConsecutive, fb)) b = max(b, bonusConsecutive, fb)
} }
} }
if s1+b < s2 { if s1+b < s2 {
@@ -600,7 +602,7 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
Csub[off] = consecutive Csub[off] = consecutive
inGap = s1 < s2 inGap = s1 < s2
score := util.Max16(util.Max16(s1, s2), 0) score := max(s1, s2, 0)
if pidx == M-1 && (forward && score > maxScore || !forward && score >= maxScore) { if pidx == M-1 && (forward && score > maxScore || !forward && score >= maxScore) {
maxScore, maxScorePos = score, col maxScore, maxScorePos = score, col
} }
@@ -684,7 +686,7 @@ func calculateScore(caseSensitive bool, normalize bool, text *util.Chars, patter
if bonus >= bonusBoundary && bonus > firstBonus { if bonus >= bonusBoundary && bonus > firstBonus {
firstBonus = bonus firstBonus = bonus
} }
bonus = util.Max16(util.Max16(bonus, firstBonus), bonusConsecutive) bonus = max(bonus, firstBonus, bonusConsecutive)
} }
if pidx == 0 { if pidx == 0 {
score += int(bonus * bonusFirstCharMultiplier) score += int(bonus * bonusFirstCharMultiplier)
+1 -1
View File
@@ -86,7 +86,7 @@ func TestFuzzyMatch(t *testing.T) {
scoreGapStart*2+scoreGapExtension*2) scoreGapStart*2+scoreGapExtension*2)
assertMatch(t, fn, true, forward, "FooBar Baz", "FooB", 0, 4, assertMatch(t, fn, true, forward, "FooBar Baz", "FooB", 0, 4,
scoreMatch*4+int(bonusBoundaryWhite)*bonusFirstCharMultiplier+int(bonusBoundaryWhite)*2+ scoreMatch*4+int(bonusBoundaryWhite)*bonusFirstCharMultiplier+int(bonusBoundaryWhite)*2+
util.Max(bonusCamel123, int(bonusBoundaryWhite))) max(bonusCamel123, int(bonusBoundaryWhite)))
// Consecutive bonus updated // Consecutive bonus updated
assertMatch(t, fn, true, forward, "foo-bar", "o-ba", 2, 6, assertMatch(t, fn, true, forward, "foo-bar", "o-ba", 2, 6,
+81 -17
View File
@@ -22,20 +22,21 @@ type url struct {
type ansiState struct { type ansiState struct {
fg tui.Color fg tui.Color
bg tui.Color bg tui.Color
ul tui.Color
attr tui.Attr attr tui.Attr
lbg tui.Color lbg tui.Color
url *url url *url
} }
func (s *ansiState) colored() bool { func (s *ansiState) colored() bool {
return s.fg != -1 || s.bg != -1 || s.attr > 0 || s.lbg >= 0 || s.url != nil return s.fg != -1 || s.bg != -1 || s.ul != -1 || s.attr > 0 || s.lbg >= 0 || s.url != nil
} }
func (s *ansiState) equals(t *ansiState) bool { func (s *ansiState) equals(t *ansiState) bool {
if t == nil { if t == nil {
return !s.colored() return !s.colored()
} }
return s.fg == t.fg && s.bg == t.bg && s.attr == t.attr && s.lbg == t.lbg && s.url == t.url return s.fg == t.fg && s.bg == t.bg && s.ul == t.ul && s.attr == t.attr && s.lbg == t.lbg && s.url == t.url
} }
func (s *ansiState) ToString() string { func (s *ansiState) ToString() string {
@@ -54,7 +55,18 @@ func (s *ansiState) ToString() string {
ret += "3;" ret += "3;"
} }
if s.attr&tui.Underline > 0 { if s.attr&tui.Underline > 0 {
ret += "4;" switch s.attr.UnderlineStyle() {
case tui.UlStyleDouble:
ret += "4:2;"
case tui.UlStyleCurly:
ret += "4:3;"
case tui.UlStyleDotted:
ret += "4:4;"
case tui.UlStyleDashed:
ret += "4:5;"
default:
ret += "4;"
}
} }
if s.attr&tui.Blink > 0 { if s.attr&tui.Blink > 0 {
ret += "5;" ret += "5;"
@@ -66,6 +78,9 @@ func (s *ansiState) ToString() string {
ret += "9;" ret += "9;"
} }
ret += toAnsiString(s.fg, 30) + toAnsiString(s.bg, 40) ret += toAnsiString(s.fg, 30) + toAnsiString(s.bg, 40)
if s.ul != -1 {
ret += toAnsiStringUl(s.ul)
}
ret = "\x1b[" + strings.TrimSuffix(ret, ";") + "m" ret = "\x1b[" + strings.TrimSuffix(ret, ";") + "m"
if s.url != nil { if s.url != nil {
@@ -74,6 +89,20 @@ func (s *ansiState) ToString() string {
return ret return ret
} }
func toAnsiStringUl(color tui.Color) string {
col := int(color)
if col < 0 {
return ""
}
if col >= (1 << 24) {
r := strconv.Itoa((col >> 16) & 0xff)
g := strconv.Itoa((col >> 8) & 0xff)
b := strconv.Itoa(col & 0xff)
return "58;2;" + r + ";" + g + ";" + b + ";"
}
return "58;5;" + strconv.Itoa(col) + ";"
}
func toAnsiString(color tui.Color, offset int) string { func toAnsiString(color tui.Color, offset int) string {
col := int(color) col := int(color)
ret := "" ret := ""
@@ -338,15 +367,19 @@ func extractColor(str string, state *ansiState, proc func(string, *ansiState) bo
return trimmed, nil, state return trimmed, nil, state
} }
func parseAnsiCode(s string) (int, string) { func parseAnsiCode(s string) (int, byte, string) {
var remaining string var remaining string
var i int var sep byte
// Faster than strings.IndexAny(";:") // Find the first separator (either ; or :)
i = strings.IndexByte(s, ';') i := -1
if i < 0 { for j := 0; j < len(s); j++ {
i = strings.IndexByte(s, ':') if s[j] == ';' || s[j] == ':' {
i = j
break
}
} }
if i >= 0 { if i >= 0 {
sep = s[i]
remaining = s[i+1:] remaining = s[i+1:]
s = s[:i] s = s[:i]
} }
@@ -358,14 +391,14 @@ func parseAnsiCode(s string) (int, string) {
for _, ch := range stringBytes(s) { for _, ch := range stringBytes(s) {
ch -= '0' ch -= '0'
if ch > 9 { if ch > 9 {
return -1, remaining return -1, sep, remaining
} }
code = code*10 + int(ch) code = code*10 + int(ch)
} }
return code, remaining return code, sep, remaining
} }
return -1, remaining return -1, sep, remaining
} }
func interpretCode(ansiCode string, prevState *ansiState) ansiState { func interpretCode(ansiCode string, prevState *ansiState) ansiState {
@@ -373,14 +406,14 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
if prevState != nil { if prevState != nil {
return *prevState return *prevState
} }
return ansiState{-1, -1, 0, -1, nil} return ansiState{-1, -1, -1, 0, -1, nil}
} }
var state ansiState var state ansiState
if prevState == nil { if prevState == nil {
state = ansiState{-1, -1, 0, -1, nil} state = ansiState{-1, -1, -1, 0, -1, nil}
} else { } else {
state = ansiState{prevState.fg, prevState.bg, prevState.attr, prevState.lbg, prevState.url} state = ansiState{prevState.fg, prevState.bg, prevState.ul, prevState.attr, prevState.lbg, prevState.url}
} }
if ansiCode[0] != '\x1b' || ansiCode[1] != '[' || ansiCode[len(ansiCode)-1] != 'm' { if ansiCode[0] != '\x1b' || ansiCode[1] != '[' || ansiCode[len(ansiCode)-1] != 'm' {
if prevState != nil && (strings.HasSuffix(ansiCode, "0K") || strings.HasSuffix(ansiCode, "[K")) { if prevState != nil && (strings.HasSuffix(ansiCode, "0K") || strings.HasSuffix(ansiCode, "[K")) {
@@ -405,6 +438,7 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
reset := func() { reset := func() {
state.fg = -1 state.fg = -1
state.bg = -1 state.bg = -1
state.ul = -1
state.attr = 0 state.attr = 0
} }
@@ -420,7 +454,8 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
count := 0 count := 0
for len(ansiCode) != 0 { for len(ansiCode) != 0 {
var num int var num int
if num, ansiCode = parseAnsiCode(ansiCode); num != -1 { var sep byte
if num, sep, ansiCode = parseAnsiCode(ansiCode); num != -1 {
count++ count++
switch state256 { switch state256 {
case 0: case 0:
@@ -431,10 +466,15 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
case 48: case 48:
ptr = &state.bg ptr = &state.bg
state256++ state256++
case 58:
ptr = &state.ul
state256++
case 39: case 39:
state.fg = -1 state.fg = -1
case 49: case 49:
state.bg = -1 state.bg = -1
case 59:
state.ul = -1
case 1: case 1:
state.attr = state.attr | tui.Bold state.attr = state.attr | tui.Bold
case 2: case 2:
@@ -442,7 +482,30 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
case 3: case 3:
state.attr = state.attr | tui.Italic state.attr = state.attr | tui.Italic
case 4: case 4:
state.attr = state.attr | tui.Underline if sep == ':' {
// SGR 4:N — underline style sub-parameter
var subNum int
subNum, _, ansiCode = parseAnsiCode(ansiCode)
state.attr = state.attr &^ tui.UnderlineStyleMask
switch subNum {
case 0:
state.attr = state.attr &^ tui.Underline
case 1:
state.attr = state.attr | tui.Underline
case 2:
state.attr = state.attr | tui.Underline | tui.UlStyleDouble
case 3:
state.attr = state.attr | tui.Underline | tui.UlStyleCurly
case 4:
state.attr = state.attr | tui.Underline | tui.UlStyleDotted
case 5:
state.attr = state.attr | tui.Underline | tui.UlStyleDashed
default:
state.attr = state.attr | tui.Underline
}
} else {
state.attr = state.attr | tui.Underline
}
case 5: case 5:
state.attr = state.attr | tui.Blink state.attr = state.attr | tui.Blink
case 7: case 7:
@@ -456,6 +519,7 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
state.attr = state.attr &^ tui.Italic state.attr = state.attr &^ tui.Italic
case 24: // tput rmul case 24: // tput rmul
state.attr = state.attr &^ tui.Underline state.attr = state.attr &^ tui.Underline
state.attr = state.attr &^ tui.UnderlineStyleMask
case 25: case 25:
state.attr = state.attr &^ tui.Blink state.attr = state.attr &^ tui.Blink
case 27: case 27:
+123 -17
View File
@@ -369,10 +369,10 @@ func TestAnsiCodeStringConversion(t *testing.T) {
} }
} }
assert("\x1b[m", nil, "") assert("\x1b[m", nil, "")
assert("\x1b[m", &ansiState{attr: tui.Blink, lbg: -1}, "") assert("\x1b[m", &ansiState{attr: tui.Blink, ul: -1, lbg: -1}, "")
assert("\x1b[0m", &ansiState{fg: 4, bg: 4, lbg: -1}, "") assert("\x1b[0m", &ansiState{fg: 4, bg: 4, ul: -1, lbg: -1}, "")
assert("\x1b[;m", &ansiState{fg: 4, bg: 4, lbg: -1}, "") assert("\x1b[;m", &ansiState{fg: 4, bg: 4, ul: -1, lbg: -1}, "")
assert("\x1b[;;m", &ansiState{fg: 4, bg: 4, lbg: -1}, "") assert("\x1b[;;m", &ansiState{fg: 4, bg: 4, ul: -1, lbg: -1}, "")
assert("\x1b[31m", nil, "\x1b[31;49m") assert("\x1b[31m", nil, "\x1b[31;49m")
assert("\x1b[41m", nil, "\x1b[39;41m") assert("\x1b[41m", nil, "\x1b[39;41m")
@@ -380,36 +380,142 @@ func TestAnsiCodeStringConversion(t *testing.T) {
assert("\x1b[92m", nil, "\x1b[92;49m") assert("\x1b[92m", nil, "\x1b[92;49m")
assert("\x1b[102m", nil, "\x1b[39;102m") assert("\x1b[102m", nil, "\x1b[39;102m")
assert("\x1b[31m", &ansiState{fg: 4, bg: 4, lbg: -1}, "\x1b[31;44m") assert("\x1b[31m", &ansiState{fg: 4, bg: 4, ul: -1, lbg: -1}, "\x1b[31;44m")
assert("\x1b[1;2;31m", &ansiState{fg: 2, bg: -1, attr: tui.Reverse, lbg: -1}, "\x1b[1;2;7;31;49m") assert("\x1b[1;2;31m", &ansiState{fg: 2, bg: -1, ul: -1, attr: tui.Reverse, lbg: -1}, "\x1b[1;2;7;31;49m")
assert("\x1b[38;5;100;48;5;200m", nil, "\x1b[38;5;100;48;5;200m") assert("\x1b[38;5;100;48;5;200m", nil, "\x1b[38;5;100;48;5;200m")
assert("\x1b[38:5:100:48:5:200m", nil, "\x1b[38;5;100;48;5;200m") assert("\x1b[38:5:100:48:5:200m", nil, "\x1b[38;5;100;48;5;200m")
assert("\x1b[48;5;100;38;5;200m", nil, "\x1b[38;5;200;48;5;100m") assert("\x1b[48;5;100;38;5;200m", nil, "\x1b[38;5;200;48;5;100m")
assert("\x1b[48;5;100;38;2;10;20;30;1m", nil, "\x1b[1;38;2;10;20;30;48;5;100m") assert("\x1b[48;5;100;38;2;10;20;30;1m", nil, "\x1b[1;38;2;10;20;30;48;5;100m")
assert("\x1b[48;5;100;38;2;10;20;30;7m", assert("\x1b[48;5;100;38;2;10;20;30;7m",
&ansiState{attr: tui.Dim | tui.Italic, fg: 1, bg: 1}, &ansiState{attr: tui.Dim | tui.Italic, fg: 1, bg: 1, ul: -1},
"\x1b[2;3;7;38;2;10;20;30;48;5;100m") "\x1b[2;3;7;38;2;10;20;30;48;5;100m")
// Underline styles
assert("\x1b[4:3m", nil, "\x1b[4:3;39;49m")
assert("\x1b[4:2m", nil, "\x1b[4:2;39;49m")
assert("\x1b[4:4m", nil, "\x1b[4:4;39;49m")
assert("\x1b[4:5m", nil, "\x1b[4:5;39;49m")
assert("\x1b[4:1m", nil, "\x1b[4;39;49m")
// Underline color (256-color)
assert("\x1b[4;58;5;100m", nil, "\x1b[4;39;49;58;5;100m")
// Underline color (24-bit)
assert("\x1b[4;58;2;255;0;128m", nil, "\x1b[4;39;49;58;2;255;0;128m")
// Curly underline + underline color
assert("\x1b[4:3;58;2;255;0;0m", nil, "\x1b[4:3;39;49;58;2;255;0;0m")
// SGR 59 resets underline color
assert("\x1b[59m", &ansiState{fg: 1, bg: -1, ul: 100, lbg: -1}, "\x1b[31;49m")
} }
func TestParseAnsiCode(t *testing.T) { func TestParseAnsiCode(t *testing.T) {
tests := []struct { tests := []struct {
In, Exp string In string
N int Exp string
N int
Sep byte
}{ }{
{"123", "", 123}, {"123", "", 123, 0},
{"1a", "", -1}, {"1a", "", -1, 0},
{"1a;12", "12", -1}, {"1a;12", "12", -1, ';'},
{"12;a", "a", 12}, {"12;a", "a", 12, ';'},
{"-2", "", -1}, {"-2", "", -1, 0},
// Colon sub-parameters: earliest separator wins (@shtse8)
{"4:3", "3", 4, ':'},
{"4:3;31", "3;31", 4, ':'},
{"38:2:255:0:0", "2:255:0:0", 38, ':'},
{"58:5:200", "5:200", 58, ':'},
// Semicolon before colon
{"4;38:2:0:0:0", "38:2:0:0:0", 4, ';'},
} }
for _, x := range tests { for _, x := range tests {
n, s := parseAnsiCode(x.In) n, sep, s := parseAnsiCode(x.In)
if n != x.N || s != x.Exp { if n != x.N || s != x.Exp || sep != x.Sep {
t.Fatalf("%q: got: (%d %q) want: (%d %q)", x.In, n, s, x.N, x.Exp) t.Fatalf("%q: got: (%d %q %q) want: (%d %q %q)", x.In, n, s, string(sep), x.N, x.Exp, string(x.Sep))
} }
} }
} }
// Test cases adapted from @shtse8 (PR #4678)
func TestInterpretCodeUnderlineStyles(t *testing.T) {
// 4:0 = no underline
state := interpretCode("\x1b[4:0m", nil)
if state.attr&tui.Underline != 0 {
t.Error("4:0 should not set underline")
}
// 4:1 = single underline
state = interpretCode("\x1b[4:1m", nil)
if state.attr&tui.Underline == 0 {
t.Error("4:1 should set underline")
}
// 4:3 = curly underline
state = interpretCode("\x1b[4:3m", nil)
if state.attr&tui.Underline == 0 {
t.Error("4:3 should set underline")
}
if state.attr.UnderlineStyle() != tui.UlStyleCurly {
t.Error("4:3 should set curly underline style")
}
// 4:3 should NOT set italic (3 is a sub-param, not SGR 3)
if state.attr&tui.Italic != 0 {
t.Error("4:3 should not set italic")
}
// 4:2;31 = double underline + red fg
state = interpretCode("\x1b[4:2;31m", nil)
if state.attr&tui.Underline == 0 {
t.Error("4:2;31 should set underline")
}
if state.fg != 1 {
t.Errorf("4:2;31 should set fg to red (1), got %d", state.fg)
}
if state.attr&tui.Dim != 0 {
t.Error("4:2;31 should not set dim")
}
// Plain 4 still works
state = interpretCode("\x1b[4m", nil)
if state.attr&tui.Underline == 0 {
t.Error("4 should set underline")
}
// 4;2 (semicolon) = underline + dim
state = interpretCode("\x1b[4;2m", nil)
if state.attr&tui.Underline == 0 {
t.Error("4;2 should set underline")
}
if state.attr&tui.Dim == 0 {
t.Error("4;2 should set dim")
}
}
// Test cases adapted from @shtse8 (PR #4678)
func TestInterpretCodeUnderlineColor(t *testing.T) {
// 58:2:R:G:B should not affect fg or bg
state := interpretCode("\x1b[58:2:255:0:0m", nil)
if state.fg != -1 || state.bg != -1 {
t.Errorf("58:2:R:G:B should not affect fg/bg, got fg=%d bg=%d", state.fg, state.bg)
}
// 58:5:200 should not affect fg or bg
state = interpretCode("\x1b[58:5:200m", nil)
if state.fg != -1 || state.bg != -1 {
t.Errorf("58:5:N should not affect fg/bg, got fg=%d bg=%d", state.fg, state.bg)
}
// 58:2:R:G:B combined with 38:2:R:G:B should only set fg
state = interpretCode("\x1b[58:2:255:0:0;38:2:0:255:0m", nil)
expectedFg := tui.Color(1<<24 | 0<<16 | 255<<8 | 0)
if state.fg != expectedFg {
t.Errorf("expected fg=%d, got %d", expectedFg, state.fg)
}
if state.bg != -1 {
t.Errorf("bg should be -1, got %d", state.bg)
}
}
// kernel/bpf/preload/iterators/README // kernel/bpf/preload/iterators/README
const ansiBenchmarkString = "\x1b[38;5;81m\x1b[01;31m\x1b[Kkernel/\x1b[0m\x1b[38:5:81mbpf/" + const ansiBenchmarkString = "\x1b[38;5;81m\x1b[01;31m\x1b[Kkernel/\x1b[0m\x1b[38:5:81mbpf/" +
"\x1b[0m\x1b[38:5:81mpreload/\x1b[0m\x1b[38;5;81miterators/" + "\x1b[0m\x1b[38:5:81mpreload/\x1b[0m\x1b[38;5;81miterators/" +
+20 -14
View File
@@ -38,6 +38,18 @@ func (r revision) compatible(other revision) bool {
return r.major == other.major return r.major == other.major
} }
func buildItemTransformer(opts *Options) func(*Item) string {
if opts.AcceptNth != nil {
fn := opts.AcceptNth(opts.Delimiter)
return func(item *Item) string {
return item.acceptNth(opts.Ansi, opts.Delimiter, fn)
}
}
return func(item *Item) string {
return item.AsString(opts.Ansi)
}
}
// Run starts fzf // Run starts fzf
func Run(opts *Options) (int, error) { func Run(opts *Options) (int, error) {
if opts.Filter == nil { if opts.Filter == nil {
@@ -147,7 +159,7 @@ func Run(opts *Options) (int, error) {
if item.colors != nil { if item.colors != nil {
for _, ansi := range *item.colors { for _, ansi := range *item.colors {
if ansi.color.bg >= 0 { if ansi.color.bg >= 0 {
maxColorOffset = util.Max32(maxColorOffset, ansi.offset[1]) maxColorOffset = max(maxColorOffset, ansi.offset[1])
} }
} }
} }
@@ -243,6 +255,8 @@ func Run(opts *Options) (int, error) {
pattern := patternBuilder([]rune(*opts.Filter)) pattern := patternBuilder([]rune(*opts.Filter))
matcher.sort = pattern.sortable matcher.sort = pattern.sortable
transformer := buildItemTransformer(opts)
found := false found := false
if streamingFilter { if streamingFilter {
slab := util.MakeSlab(slab16Size, slab32Size) slab := util.MakeSlab(slab16Size, slab32Size)
@@ -253,7 +267,7 @@ func Run(opts *Options) (int, error) {
if chunkList.trans(&item, runes) { if chunkList.trans(&item, runes) {
mutex.Lock() mutex.Lock()
if result, _, _ := pattern.MatchItem(&item, false, slab); result != nil { if result, _, _ := pattern.MatchItem(&item, false, slab); result != nil {
opts.Printer(item.text.ToString()) opts.Printer(transformer(&item))
found = true found = true
} }
mutex.Unlock() mutex.Unlock()
@@ -271,7 +285,7 @@ func Run(opts *Options) (int, error) {
chunks: snapshot, chunks: snapshot,
pattern: pattern}) pattern: pattern})
for i := 0; i < result.merger.Length(); i++ { for i := 0; i < result.merger.Length(); i++ {
opts.Printer(result.merger.Get(i).item.AsString(opts.Ansi)) opts.Printer(transformer(result.merger.Get(i).item))
found = true found = true
} }
} }
@@ -319,7 +333,7 @@ func Run(opts *Options) (int, error) {
if total >= maxFit || final { if total >= maxFit || final {
deferred = false deferred = false
heightUnknown = false heightUnknown = false
terminal.startChan <- fitpad{util.Min(total, maxFit), padHeight} terminal.startChan <- fitpad{min(total, maxFit), padHeight}
} }
} else if deferred { } else if deferred {
deferred = false deferred = false
@@ -493,15 +507,7 @@ func Run(opts *Options) (int, error) {
if len(opts.Expect) > 0 { if len(opts.Expect) > 0 {
opts.Printer("") opts.Printer("")
} }
transformer := func(item *Item) string { transformer := buildItemTransformer(opts)
return item.AsString(opts.Ansi)
}
if opts.AcceptNth != nil {
fn := opts.AcceptNth(opts.Delimiter)
transformer = func(item *Item) string {
return item.acceptNth(opts.Ansi, opts.Delimiter, fn)
}
}
for i := range count { for i := range count {
opts.Printer(transformer(merger.Get(i).item)) opts.Printer(transformer(merger.Get(i).item))
} }
@@ -524,7 +530,7 @@ func Run(opts *Options) (int, error) {
break break
} }
if delay && reading { if delay && reading {
dur := util.DurWithin( dur := util.Constrain(
time.Duration(ticks-startTick)*coordinatorDelayStep, time.Duration(ticks-startTick)*coordinatorDelayStep,
0, coordinatorDelayMax) 0, coordinatorDelayMax)
time.Sleep(dur) time.Sleep(dur)
+1 -1
View File
@@ -55,7 +55,7 @@ const (
// NewMatcher returns a new Matcher // NewMatcher returns a new Matcher
func NewMatcher(cache *ChunkCache, patternBuilder func([]rune) *Pattern, func NewMatcher(cache *ChunkCache, patternBuilder func([]rune) *Pattern,
sort bool, tac bool, eventBox *util.EventBox, revision revision) *Matcher { sort bool, tac bool, eventBox *util.EventBox, revision revision) *Matcher {
partitions := util.Min(numPartitionsMultiplier*runtime.NumCPU(), maxPartitions) partitions := min(numPartitionsMultiplier*runtime.NumCPU(), maxPartitions)
return &Matcher{ return &Matcher{
cache: cache, cache: cache,
patternBuilder: patternBuilder, patternBuilder: patternBuilder,
+84 -10
View File
@@ -95,7 +95,7 @@ Usage: fzf [options]
-m, --multi[=MAX] Enable multi-select with tab/shift-tab -m, --multi[=MAX] Enable multi-select with tab/shift-tab
--highlight-line Highlight the whole current line --highlight-line Highlight the whole current line
--cycle Enable cyclic scroll --cycle Enable cyclic scroll
--wrap Enable line wrap --wrap[=MODE] Enable line wrap (char|word, default: char)
--wrap-sign=STR Indicator for wrapped lines --wrap-sign=STR Indicator for wrapped lines
--no-multi-line Disable multi-line display of items when using --read0 --no-multi-line Disable multi-line display of items when using --read0
--raw Enable raw mode (show non-matching items) --raw Enable raw mode (show non-matching items)
@@ -157,7 +157,7 @@ Usage: fzf [options]
--preview=COMMAND Command to preview highlighted line ({}) --preview=COMMAND Command to preview highlighted line ({})
--preview-window=OPT Preview window layout (default: right:50%) --preview-window=OPT Preview window layout (default: right:50%)
[up|down|left|right][,SIZE[%]] [up|down|left|right][,SIZE[%]]
[,[no]wrap][,[no]cycle][,[no]follow][,[no]info] [,[no]wrap[-word]][,[no]cycle][,[no]follow][,[no]info]
[,[no]hidden][,border-STYLE] [,[no]hidden][,border-STYLE]
[,+SCROLL[OFFSETS][/DENOM]][,~HEADER_LINES] [,+SCROLL[OFFSETS][/DENOM]][,~HEADER_LINES]
[,default][,<SIZE_THRESHOLD(ALTERNATIVE_LAYOUT)] [,default][,<SIZE_THRESHOLD(ALTERNATIVE_LAYOUT)]
@@ -167,6 +167,7 @@ Usage: fzf [options]
--preview-label=LABEL --preview-label=LABEL
--preview-label-pos=N Same as --border-label and --border-label-pos, --preview-label-pos=N Same as --border-label and --border-label-pos,
but for preview window but for preview window
--preview-wrap-sign=STR Indicator for wrapped lines in the preview window
HEADER HEADER
--header=STR String to print as header --header=STR String to print as header
@@ -292,14 +293,32 @@ func defaultMargin() [4]sizeSpec {
return [4]sizeSpec{} return [4]sizeSpec{}
} }
type trackOption int type trackOption struct {
enabled bool
index int32
}
const ( var (
trackDisabled trackOption = iota trackDisabled = trackOption{false, minItem.Index()}
trackEnabled trackEnabled = trackOption{true, minItem.Index()}
trackCurrent
) )
func (t trackOption) Disabled() bool {
return !t.enabled
}
func (t trackOption) Global() bool {
return t.enabled && t.index == minItem.Index()
}
func (t trackOption) Current() bool {
return t.enabled && t.index != minItem.Index()
}
func trackCurrent(index int32) trackOption {
return trackOption{true, index}
}
type windowPosition int type windowPosition int
const ( const (
@@ -349,6 +368,7 @@ type previewOpts struct {
scroll string scroll string
hidden bool hidden bool
wrap bool wrap bool
wrapWord bool
cycle bool cycle bool
follow bool follow bool
info bool info bool
@@ -525,7 +545,7 @@ func (o *previewOpts) compare(active *previewOpts, b *previewOpts) previewOptsCo
return previewOptsDifferentLayout return previewOptsDifferentLayout
} }
if a.wrap == b.wrap && a.headerLines == b.headerLines && a.info == b.info && a.scroll == b.scroll { if a.wrap == b.wrap && a.wrapWord == b.wrapWord && a.headerLines == b.headerLines && a.info == b.info && a.scroll == b.scroll {
return previewOptsSame return previewOptsSame
} }
@@ -587,7 +607,9 @@ type Options struct {
Layout layoutType Layout layoutType
Cycle bool Cycle bool
Wrap bool Wrap bool
WrapWord bool
WrapSign *string WrapSign *string
PreviewWrapSign *string
MultiLine bool MultiLine bool
CursorLine bool CursorLine bool
KeepRight bool KeepRight bool
@@ -673,7 +695,13 @@ func filterNonEmpty(input []string) []string {
} }
func defaultPreviewOpts(command string) previewOpts { func defaultPreviewOpts(command string) previewOpts {
return previewOpts{command, posRight, sizeSpec{50, true}, "", false, false, false, false, true, defaultBorderShape, 0, 0, nil} return previewOpts{
command: command,
position: posRight,
size: sizeSpec{50, true},
info: true,
border: defaultBorderShape,
}
} }
func defaultOptions() *Options { func defaultOptions() *Options {
@@ -715,6 +743,7 @@ func defaultOptions() *Options {
Layout: layoutDefault, Layout: layoutDefault,
Cycle: false, Cycle: false,
Wrap: false, Wrap: false,
WrapWord: false,
MultiLine: true, MultiLine: true,
KeepRight: false, KeepRight: false,
Hscroll: true, Hscroll: true,
@@ -1389,6 +1418,14 @@ func parseTheme(defaultTheme *tui.ColorTheme, str string) (*tui.ColorTheme, *tui
cattr.Attr |= tui.Italic cattr.Attr |= tui.Italic
case "underline": case "underline":
cattr.Attr |= tui.Underline cattr.Attr |= tui.Underline
case "underline-double":
cattr.Attr |= tui.Underline | tui.UlStyleDouble
case "underline-curly":
cattr.Attr |= tui.Underline | tui.UlStyleCurly
case "underline-dotted":
cattr.Attr |= tui.Underline | tui.UlStyleDotted
case "underline-dashed":
cattr.Attr |= tui.Underline | tui.UlStyleDashed
case "blink": case "blink":
cattr.Attr |= tui.Blink cattr.Attr |= tui.Blink
case "reverse": case "reverse":
@@ -1476,6 +1513,8 @@ func parseTheme(defaultTheme *tui.ColorTheme, str string) (*tui.ColorTheme, *tui
mergeAttr(&theme.Nomatch) mergeAttr(&theme.Nomatch)
case "gutter": case "gutter":
mergeAttr(&theme.Gutter) mergeAttr(&theme.Gutter)
case "alt-gutter":
mergeAttr(&theme.AltGutter)
case "hl": case "hl":
mergeAttr(&theme.Match) mergeAttr(&theme.Match)
case "current-hl", "hl+": case "current-hl", "hl+":
@@ -1769,6 +1808,8 @@ func parseActionList(masked string, original string, prevActions []*action, putA
appendAction(actToggleHeader) appendAction(actToggleHeader)
case "toggle-wrap": case "toggle-wrap":
appendAction(actToggleWrap) appendAction(actToggleWrap)
case "toggle-wrap-word":
appendAction(actToggleWrapWord)
case "toggle-multi-line": case "toggle-multi-line":
appendAction(actToggleMultiLine) appendAction(actToggleMultiLine)
case "toggle-hscroll": case "toggle-hscroll":
@@ -1835,6 +1876,8 @@ func parseActionList(masked string, original string, prevActions []*action, putA
appendAction(actTogglePreview) appendAction(actTogglePreview)
case "toggle-preview-wrap": case "toggle-preview-wrap":
appendAction(actTogglePreviewWrap) appendAction(actTogglePreviewWrap)
case "toggle-preview-wrap-word":
appendAction(actTogglePreviewWrapWord)
case "toggle-sort": case "toggle-sort":
appendAction(actToggleSort) appendAction(actToggleSort)
case "offset-up": case "offset-up":
@@ -2246,8 +2289,13 @@ func parsePreviewWindowImpl(opts *previewOpts, input string) error {
opts.hidden = false opts.hidden = false
case "wrap": case "wrap":
opts.wrap = true opts.wrap = true
opts.wrapWord = false
case "wrap-word":
opts.wrap = true
opts.wrapWord = true
case "nowrap": case "nowrap":
opts.wrap = false opts.wrap = false
opts.wrapWord = false
case "cycle": case "cycle":
opts.cycle = true opts.cycle = true
case "nocycle": case "nocycle":
@@ -2811,9 +2859,29 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
case "--no-cycle": case "--no-cycle":
opts.Cycle = false opts.Cycle = false
case "--wrap": case "--wrap":
opts.Wrap = true given, str := optionalNextString()
if given {
switch str {
case "char":
opts.Wrap = true
opts.WrapWord = false
case "word":
opts.Wrap = true
opts.WrapWord = true
default:
return errors.New("invalid wrap mode: " + str + " (expected: char or word)")
}
} else {
opts.Wrap = true
}
case "--no-wrap": case "--no-wrap":
opts.Wrap = false opts.Wrap = false
opts.WrapWord = false
case "--wrap-word":
opts.Wrap = true
opts.WrapWord = true
case "--no-wrap-word":
opts.WrapWord = false
case "--wrap-sign": case "--wrap-sign":
str, err := nextString("wrap sign required") str, err := nextString("wrap sign required")
if err != nil { if err != nil {
@@ -3047,6 +3115,12 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
if opts.Preview.border, err = parseBorder(arg, !hasArg); err != nil { if opts.Preview.border, err = parseBorder(arg, !hasArg); err != nil {
return err return err
} }
case "--preview-wrap-sign":
str, err := nextString("preview wrap sign required")
if err != nil {
return err
}
opts.PreviewWrapSign = &str
case "--height": case "--height":
str, err := nextString("height required: [~]HEIGHT[%]") str, err := nextString("height required: [~]HEIGHT[%]")
if err != nil { if err != nil {
+58
View File
@@ -448,6 +448,64 @@ func TestPreviewOpts(t *testing.T) {
opts.Preview.size.size == 70) { opts.Preview.size.size == 70) {
t.Error(opts.Preview) t.Error(opts.Preview)
} }
// wrap-word tests
opts = optsFor("--preview-window=wrap-word")
if !(opts.Preview.wrap == true && opts.Preview.wrapWord == true) {
t.Errorf("wrap-word: wrap=%v, wrapWord=%v", opts.Preview.wrap, opts.Preview.wrapWord)
}
opts = optsFor("--preview-window=wrap-word,nowrap")
if !(opts.Preview.wrap == false && opts.Preview.wrapWord == false) {
t.Errorf("wrap-word,nowrap: wrap=%v, wrapWord=%v", opts.Preview.wrap, opts.Preview.wrapWord)
}
opts = optsFor("--preview-window=wrap-word,wrap")
if !(opts.Preview.wrap == true && opts.Preview.wrapWord == false) {
t.Errorf("wrap-word,wrap: wrap=%v, wrapWord=%v", opts.Preview.wrap, opts.Preview.wrapWord)
}
}
func TestPreviewWrapSign(t *testing.T) {
// Default: no preview wrap sign override
opts := optsFor()
if opts.PreviewWrapSign != nil {
t.Errorf("expected nil PreviewWrapSign, got %v", *opts.PreviewWrapSign)
}
// --preview-wrap-sign sets PreviewWrapSign
opts = optsFor("--preview-wrap-sign", ">> ")
if opts.PreviewWrapSign == nil || *opts.PreviewWrapSign != ">> " {
t.Errorf("expected '>> ', got %v", opts.PreviewWrapSign)
}
// --preview-wrap-sign is independent of --wrap-sign
opts = optsFor("--wrap-sign", "| ", "--preview-wrap-sign", ">> ")
if opts.WrapSign == nil || *opts.WrapSign != "| " {
t.Errorf("expected WrapSign '| ', got %v", opts.WrapSign)
}
if opts.PreviewWrapSign == nil || *opts.PreviewWrapSign != ">> " {
t.Errorf("expected PreviewWrapSign '>> ', got %v", opts.PreviewWrapSign)
}
// --preview-wrap-sign without --wrap-sign
opts = optsFor("--preview-wrap-sign", "→ ")
if opts.WrapSign != nil {
t.Errorf("expected nil WrapSign, got %v", *opts.WrapSign)
}
if opts.PreviewWrapSign == nil || *opts.PreviewWrapSign != "→ " {
t.Errorf("expected PreviewWrapSign '→ ', got %v", opts.PreviewWrapSign)
}
// Last --preview-wrap-sign wins
opts = optsFor("--preview-wrap-sign", "A ", "--preview-wrap-sign", "B ")
if opts.PreviewWrapSign == nil || *opts.PreviewWrapSign != "B " {
t.Errorf("expected PreviewWrapSign 'B ', got %v", opts.PreviewWrapSign)
}
// Empty string is allowed
opts = optsFor("--preview-wrap-sign", "")
if opts.PreviewWrapSign == nil || *opts.PreviewWrapSign != "" {
t.Errorf("expected empty PreviewWrapSign, got %v", opts.PreviewWrapSign)
}
} }
func TestAdditiveExpect(t *testing.T) { func TestAdditiveExpect(t *testing.T) {
+7 -3
View File
@@ -178,7 +178,7 @@ func (r *Reader) feed(src io.Reader) {
var err error var err error
for { for {
n := 0 n := 0
scope := slab[:util.Min(len(slab), readerBufferSize)] scope := slab[:min(len(slab), readerBufferSize)]
for range 100 { for range 100 {
n, err = src.Read(scope) n, err = src.Read(scope)
if n > 0 || err != nil { if n > 0 || err != nil {
@@ -303,8 +303,12 @@ func (r *Reader) readFiles(roots []string, opts walkerOpts, ignores []string) bo
} }
path = trimPath(path) path = trimPath(path)
if path != "." { if path != "." {
isDir := de.IsDir() isDirSymlink := isSymlinkToDir(path, de)
if isDir || opts.follow && isSymlinkToDir(path, de) { if isDirSymlink && !opts.follow {
return filepath.SkipDir
}
isDir := de.IsDir() || isDirSymlink
if isDir {
base := filepath.Base(path) base := filepath.Base(path)
if !opts.hidden && base[0] == '.' && base != ".." { if !opts.hidden && base[0] == '.' && base != ".." {
return filepath.SkipDir return filepath.SkipDir
+4 -4
View File
@@ -42,9 +42,9 @@ func buildResult(item *Item, offsets []Offset, score int) Result {
for _, offset := range offsets { for _, offset := range offsets {
b, e := int(offset[0]), int(offset[1]) b, e := int(offset[0]), int(offset[1])
if b < e { if b < e {
minBegin = util.Min(b, minBegin) minBegin = min(b, minBegin)
minEnd = util.Min(e, minEnd) minEnd = min(e, minEnd)
maxEnd = util.Max(e, maxEnd) maxEnd = max(e, maxEnd)
validOffsetFound = true validOffsetFound = true
} }
} }
@@ -206,7 +206,7 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
if bg == -1 { if bg == -1 {
bg = colBase.Bg() bg = colBase.Bg()
} }
return tui.NewColorPair(fg, bg, ansi.color.attr).MergeAttr(base) return tui.NewColorPair(fg, bg, ansi.color.attr).WithUl(ansi.color.ul).MergeAttr(base)
} }
var colors []colorOffset var colors []colorOffset
add := func(idx int) { add := func(idx int) {
+4 -4
View File
@@ -124,10 +124,10 @@ func TestColorOffset(t *testing.T) {
item := Result{ item := Result{
item: &Item{ item: &Item{
colors: &[]ansiOffset{ colors: &[]ansiOffset{
{[2]int32{0, 20}, ansiState{1, 5, 0, -1, nil}}, {[2]int32{0, 20}, ansiState{1, 5, -1, 0, -1, nil}},
{[2]int32{22, 27}, ansiState{2, 6, tui.Bold, -1, nil}}, {[2]int32{22, 27}, ansiState{2, 6, -1, tui.Bold, -1, nil}},
{[2]int32{30, 32}, ansiState{3, 7, 0, -1, nil}}, {[2]int32{30, 32}, ansiState{3, 7, -1, 0, -1, nil}},
{[2]int32{33, 40}, ansiState{4, 8, tui.Bold, -1, nil}}}}} {[2]int32{33, 40}, ansiState{4, 8, -1, tui.Bold, -1, nil}}}}}
colBase := tui.NewColorPair(89, 189, tui.AttrUndefined) colBase := tui.NewColorPair(89, 189, tui.AttrUndefined)
colMatch := tui.NewColorPair(99, 199, tui.AttrUndefined) colMatch := tui.NewColorPair(99, 199, tui.AttrUndefined)
+19 -12
View File
@@ -183,23 +183,22 @@ func (server *httpServer) handleHttpRequest(conn net.Conn) string {
}) })
section := 0 section := 0
var getMatch []string
Loop:
for scanner.Scan() { for scanner.Scan() {
text := scanner.Text() text := scanner.Text()
switch section { switch section {
case 0: case 0: // Request line
getMatch := getRegex.FindStringSubmatch(text) getMatch = getRegex.FindStringSubmatch(text)
if len(getMatch) > 0 { if len(getMatch) == 0 && !strings.HasPrefix(text, "POST / HTTP") {
response := server.getHandler(parseGetParams(getMatch[1]))
if len(response) > 0 {
return good(response)
}
return answer(httpUnavailable+jsonContentType, `{"error":"timeout"}`)
} else if !strings.HasPrefix(text, "POST / HTTP") {
return bad("invalid request method") return bad("invalid request method")
} }
section++ section++
case 1: case 1: // Request headers
if text == crlf { if text == crlf { // End of headers
if len(getMatch) > 0 {
break Loop
}
if contentLength == 0 { if contentLength == 0 {
return bad("content-length header missing") return bad("content-length header missing")
} }
@@ -219,7 +218,7 @@ func (server *httpServer) handleHttpRequest(conn net.Conn) string {
apiKey = strings.TrimSpace(pair[1]) apiKey = strings.TrimSpace(pair[1])
} }
} }
case 2: case 2: // Request body
body += text body += text
} }
} }
@@ -228,6 +227,14 @@ func (server *httpServer) handleHttpRequest(conn net.Conn) string {
return unauthorized("invalid api key") return unauthorized("invalid api key")
} }
if len(getMatch) > 0 {
response := server.getHandler(parseGetParams(getMatch[1]))
if len(response) > 0 {
return good(response)
}
return answer(httpUnavailable+jsonContentType, `{"error":"timeout"}`)
}
if len(body) < contentLength { if len(body) < contentLength {
return bad("incomplete request") return bad("incomplete request")
} }
+651 -366
View File
File diff suppressed because it is too large Load Diff
+69
View File
@@ -699,3 +699,72 @@ func readFile(path string) ([]byte, error) {
} }
} }
} }
func TestWordWrapAnsiLine(t *testing.T) {
term := &Terminal{}
// Simple wrapping
result := term.wordWrapAnsiLine("hello world", 7, 2)
if len(result) != 2 || result[0] != "hello" || result[1] != "world" {
t.Errorf("Simple: %q", result)
}
// No wrapping needed
result = term.wordWrapAnsiLine("hello", 10, 2)
if len(result) != 1 || result[0] != "hello" {
t.Errorf("No wrap: %q", result)
}
// ANSI codes preserved across split
result = term.wordWrapAnsiLine("\x1b[31mhello \x1b[32mworld", 8, 2)
if len(result) != 2 || result[0] != "\x1b[31mhello" || result[1] != "\x1b[32mworld" {
t.Errorf("ANSI: %q", result)
}
// Long word (no space) — no break, let character wrapping handle it
result = term.wordWrapAnsiLine("abcdefghij", 5, 2)
if len(result) != 1 || result[0] != "abcdefghij" {
t.Errorf("Long word: %q", result)
}
// Multiple words with continuation wrapSignWidth
result = term.wordWrapAnsiLine("aa bb cc dd", 5, 2)
// max=5 for first line, max=3 for continuations (5-2)
// "aa bb" (5 wide), split at second space -> "aa bb" | "cc" | "dd"
if len(result) != 3 || result[0] != "aa bb" || result[1] != "cc" || result[2] != "dd" {
t.Errorf("Multiple words: %q", result)
}
// Empty string
result = term.wordWrapAnsiLine("", 10, 2)
if len(result) != 1 || result[0] != "" {
t.Errorf("Empty: %q", result)
}
// OSC 8 hyperlink preserved
result = term.wordWrapAnsiLine("\x1b]8;;http://example.com\x1b\\click here\x1b]8;;\x1b\\", 8, 2)
if len(result) != 2 {
t.Errorf("Hyperlink split count: %d, %q", len(result), result)
}
// Tab handling: tab expands to tabstop-aligned width
term.tabstop = 8
// "\thi there" — tab at column 0 expands to 8, total "hi" starts at 8
// maxWidth=15: "\thi" = 10 wide, "there" = 5 wide, total 16 > 15, wrap at space
result = term.wordWrapAnsiLine("\thi there", 15, 2)
if len(result) != 2 || result[0] != "\thi" || result[1] != "there" {
t.Errorf("Tab: %q", result)
}
// Tab as word boundary: "hello"(5) + tab(3→col8) + "world"(5) = 13 total
// maxWidth=13: fits without wrapping
result = term.wordWrapAnsiLine("hello\tworld", 13, 2)
if len(result) != 1 || result[0] != "hello\tworld" {
t.Errorf("Tab no wrap: %q", result)
}
// maxWidth=12: 13 > 12, wraps at tab
result = term.wordWrapAnsiLine("hello\tworld", 12, 2)
if len(result) != 2 || result[0] != "hello" || result[1] != "world" {
t.Errorf("Tab wrap: %q", result)
}
}
+1 -1
View File
@@ -302,7 +302,7 @@ func Transform(tokens []Token, withNth []Range) []Token {
end += numTokens + 1 end += numTokens + 1
} }
} }
minIdx = util.Max(0, begin-1) minIdx = max(0, begin-1)
for idx := begin; idx <= end; idx++ { for idx := begin; idx <= end; idx++ {
if idx >= 1 && idx <= numTokens { if idx >= 1 && idx <= numTokens {
parts = append(parts, tokens[idx-1].text) parts = append(parts, tokens[idx-1].text)
+5 -5
View File
@@ -34,11 +34,11 @@ func (r *FullscreenRenderer) ShowCursor() {}
func (r *FullscreenRenderer) Refresh() {} func (r *FullscreenRenderer) Refresh() {}
func (r *FullscreenRenderer) Close() {} func (r *FullscreenRenderer) Close() {}
func (r *FullscreenRenderer) Size() TermSize { return TermSize{} } func (r *FullscreenRenderer) Size() TermSize { return TermSize{} }
func (r *FullscreenRenderer) Top() int { return 0 }
func (r *FullscreenRenderer) GetChar() Event { return Event{} } func (r *FullscreenRenderer) MaxX() int { return 0 }
func (r *FullscreenRenderer) Top() int { return 0 } func (r *FullscreenRenderer) MaxY() int { return 0 }
func (r *FullscreenRenderer) MaxX() int { return 0 } func (r *FullscreenRenderer) GetChar(bool) Event { return Event{} }
func (r *FullscreenRenderer) MaxY() int { return 0 } func (r *FullscreenRenderer) CancelGetChar() {}
func (r *FullscreenRenderer) RefreshWindows(windows []Window) {} func (r *FullscreenRenderer) RefreshWindows(windows []Window) {}
+104 -72
View File
@@ -13,7 +13,6 @@ import (
"unicode/utf8" "unicode/utf8"
"github.com/junegunn/fzf/src/util" "github.com/junegunn/fzf/src/util"
"github.com/rivo/uniseg"
"golang.org/x/term" "golang.org/x/term"
) )
@@ -26,6 +25,7 @@ const (
escPollInterval = 5 escPollInterval = 5
offsetPollTries = 10 offsetPollTries = 10
maxInputBuffer = 1024 * 1024 maxInputBuffer = 1024 * 1024
maxSelectTries = 100
) )
const DefaultTtyDevice string = "/dev/tty" const DefaultTtyDevice string = "/dev/tty"
@@ -49,6 +49,18 @@ const DIM string = "\x1b[2m"
const CR string = DIM + "␍" const CR string = DIM + "␍"
const LF string = DIM + "␊" const LF string = DIM + "␊"
type getCharResult int
const (
getCharSuccess getCharResult = iota
getCharError
getCharCancelled
)
func (r getCharResult) ok() bool {
return r == getCharSuccess
}
func (r *LightRenderer) stderrInternal(str string, allowNLCR bool, resetCode string) { func (r *LightRenderer) stderrInternal(str string, allowNLCR bool, resetCode string) {
bytes := []byte(str) bytes := []byte(str)
runes := []rune{} runes := []rune{}
@@ -104,6 +116,7 @@ type LightRenderer struct {
clicks [][2]int clicks [][2]int
ttyin *os.File ttyin *os.File
ttyout *os.File ttyout *os.File
cancel func()
buffer []byte buffer []byte
origState *term.State origState *term.State
width int width int
@@ -118,9 +131,9 @@ type LightRenderer struct {
x int x int
maxHeightFunc func(int) int maxHeightFunc func(int) int
showCursor bool showCursor bool
mutex sync.Mutex
// Windows only // Windows only
mutex sync.Mutex
ttyinChannel chan byte ttyinChannel chan byte
inHandle uintptr inHandle uintptr
outHandle uintptr outHandle uintptr
@@ -193,11 +206,6 @@ func (r *LightRenderer) Init() error {
if r.fullscreen { if r.fullscreen {
r.smcup() r.smcup()
} else { } else {
// We assume that --no-clear is used for repetitive relaunching of fzf.
// So we do not clear the lower bottom of the screen.
if r.clearOnExit {
r.csi("J")
}
y, x := r.findOffset() y, x := r.findOffset()
r.mouse = r.mouse && y >= 0 r.mouse = r.mouse && y >= 0
// When --no-clear is used for repetitive relaunching, there is a small // When --no-clear is used for repetitive relaunching, there is a small
@@ -208,6 +216,11 @@ func (r *LightRenderer) Init() error {
r.upOneLine = true r.upOneLine = true
r.makeSpace() r.makeSpace()
} }
// We assume that --no-clear is used for repetitive relaunching of fzf.
// So we do not clear the lower bottom of the screen.
if r.clearOnExit {
r.csi("J")
}
for i := 1; i < r.MaxY(); i++ { for i := 1; i < r.MaxY(); i++ {
r.makeSpace() r.makeSpace()
} }
@@ -262,16 +275,18 @@ func getEnv(name string, defaultValue int) int {
return atoi(env, defaultValue) return atoi(env, defaultValue)
} }
func (r *LightRenderer) getBytes() ([]byte, error) { func (r *LightRenderer) getBytes(cancellable bool) ([]byte, getCharResult, error) {
bytes, err := r.getBytesInternal(r.buffer, false) return r.getBytesInternal(cancellable, r.buffer, false)
return bytes, err
} }
func (r *LightRenderer) getBytesInternal(buffer []byte, nonblock bool) ([]byte, error) { func (r *LightRenderer) getBytesInternal(cancellable bool, buffer []byte, nonblock bool) ([]byte, getCharResult, error) {
c, ok := r.getch(nonblock) c, result := r.getch(cancellable, nonblock)
if !nonblock && !ok { if result == getCharCancelled {
return buffer, getCharCancelled, nil
}
if !nonblock && !result.ok() {
r.Close() r.Close()
return nil, errors.New("failed to read " + DefaultTtyDevice) return nil, getCharError, errors.New("failed to read " + DefaultTtyDevice)
} }
retries := 0 retries := 0
@@ -282,8 +297,8 @@ func (r *LightRenderer) getBytesInternal(buffer []byte, nonblock bool) ([]byte,
pc := c pc := c
for { for {
c, ok = r.getch(true) c, result = r.getch(false, true)
if !ok { if !result.ok() {
if retries > 0 { if retries > 0 {
retries-- retries--
time.Sleep(escPollInterval * time.Millisecond) time.Sleep(escPollInterval * time.Millisecond)
@@ -302,20 +317,24 @@ func (r *LightRenderer) getBytesInternal(buffer []byte, nonblock bool) ([]byte,
// so terminate fzf immediately. // so terminate fzf immediately.
if len(buffer) > maxInputBuffer { if len(buffer) > maxInputBuffer {
r.Close() r.Close()
return nil, fmt.Errorf("input buffer overflow (%d): %v", len(buffer), buffer) return nil, getCharError, fmt.Errorf("input buffer overflow (%d): %v", len(buffer), buffer)
} }
} }
return buffer, nil return buffer, getCharSuccess, nil
} }
func (r *LightRenderer) GetChar() Event { func (r *LightRenderer) GetChar(cancellable bool) Event {
var err error var err error
var result getCharResult
if len(r.buffer) == 0 { if len(r.buffer) == 0 {
r.buffer, err = r.getBytes() r.buffer, result, err = r.getBytes(cancellable)
if err != nil { if err != nil {
return Event{Fatal, 0, nil} return Event{Fatal, 0, nil}
} }
if result == getCharCancelled {
return Event{Invalid, 0, nil}
}
} }
if len(r.buffer) == 0 { if len(r.buffer) == 0 {
return Event{Fatal, 0, nil} return Event{Fatal, 0, nil}
@@ -351,9 +370,14 @@ func (r *LightRenderer) GetChar() Event {
ev := r.escSequence(&sz) ev := r.escSequence(&sz)
// Second chance // Second chance
if ev.Type == Invalid { if ev.Type == Invalid {
if r.buffer, err = r.getBytes(); err != nil { r.buffer, result, err = r.getBytes(true)
if err != nil {
return Event{Fatal, 0, nil} return Event{Fatal, 0, nil}
} }
if result == getCharCancelled {
return Event{Invalid, 0, nil}
}
ev = r.escSequence(&sz) ev = r.escSequence(&sz)
} }
return ev return ev
@@ -371,6 +395,21 @@ func (r *LightRenderer) GetChar() Event {
return Event{Rune, char, nil} return Event{Rune, char, nil}
} }
func (r *LightRenderer) CancelGetChar() {
r.mutex.Lock()
if r.cancel != nil {
r.cancel()
r.cancel = nil
}
r.mutex.Unlock()
}
func (r *LightRenderer) setCancel(f func()) {
r.mutex.Lock()
r.cancel = f
r.mutex.Unlock()
}
func (r *LightRenderer) escSequence(sz *int) Event { func (r *LightRenderer) escSequence(sz *int) Event {
if len(r.buffer) < 2 { if len(r.buffer) < 2 {
return Event{Esc, 0, nil} return Event{Esc, 0, nil}
@@ -1033,8 +1072,8 @@ func (r *LightRenderer) MaxY() int {
} }
func (r *LightRenderer) NewWindow(top int, left int, width int, height int, windowType WindowType, borderStyle BorderStyle, erase bool) Window { func (r *LightRenderer) NewWindow(top int, left int, width int, height int, windowType WindowType, borderStyle BorderStyle, erase bool) Window {
width = util.Max(0, width) width = max(0, width)
height = util.Max(0, height) height = max(0, height)
w := &LightWindow{ w := &LightWindow{
renderer: r, renderer: r,
colored: r.theme.Colored, colored: r.theme.Colored,
@@ -1283,7 +1322,18 @@ func attrCodes(attr Attr) []string {
codes = append(codes, "3") codes = append(codes, "3")
} }
if (attr & Underline) > 0 { if (attr & Underline) > 0 {
codes = append(codes, "4") switch attr.UnderlineStyle() {
case UlStyleDouble:
codes = append(codes, "4:2")
case UlStyleCurly:
codes = append(codes, "4:3")
case UlStyleDotted:
codes = append(codes, "4:4")
case UlStyleDashed:
codes = append(codes, "4:5")
default:
codes = append(codes, "4")
}
} }
if (attr & Blink) > 0 { if (attr & Blink) > 0 {
codes = append(codes, "5") codes = append(codes, "5")
@@ -1321,8 +1371,27 @@ func colorCodes(fg Color, bg Color) []string {
return codes return codes
} }
func (w *LightWindow) csiColor(fg Color, bg Color, attr Attr) (bool, string) { func ulColorCode(c Color) string {
if c == colDefault {
return ""
}
if c.is24() {
r := (c >> 16) & 0xff
g := (c >> 8) & 0xff
b := (c) & 0xff
return fmt.Sprintf("58;2;%d;%d;%d", r, g, b)
}
if c >= 0 && c < 256 {
return fmt.Sprintf("58;5;%d", c)
}
return ""
}
func (w *LightWindow) csiColor(fg Color, bg Color, ul Color, attr Attr) (bool, string) {
codes := append(attrCodes(attr), colorCodes(fg, bg)...) codes := append(attrCodes(attr), colorCodes(fg, bg)...)
if ulCode := ulColorCode(ul); ulCode != "" {
codes = append(codes, ulCode)
}
code := w.csi(";" + strings.Join(codes, ";") + "m") code := w.csi(";" + strings.Join(codes, ";") + "m")
return len(codes) > 0, code return len(codes) > 0, code
} }
@@ -1336,65 +1405,28 @@ func cleanse(str string) string {
} }
func (w *LightWindow) CPrint(pair ColorPair, text string) { func (w *LightWindow) CPrint(pair ColorPair, text string) {
_, code := w.csiColor(pair.Fg(), pair.Bg(), pair.Attr()) _, code := w.csiColor(pair.Fg(), pair.Bg(), pair.Ul(), pair.Attr())
w.stderrInternal(cleanse(text), false, code) w.stderrInternal(cleanse(text), false, code)
w.csi("0m") w.csi("0m")
} }
func (w *LightWindow) cprint2(fg Color, bg Color, attr Attr, text string) { func (w *LightWindow) cprint2(fg Color, bg Color, attr Attr, text string) {
hasColors, code := w.csiColor(fg, bg, attr) hasColors, code := w.csiColor(fg, bg, colDefault, attr)
if hasColors { if hasColors {
defer w.csi("0m") defer w.csi("0m")
} }
w.stderrInternal(cleanse(text), false, code) w.stderrInternal(cleanse(text), false, code)
} }
type wrappedLine struct {
text string
displayWidth int
}
func wrapLine(input string, prefixLength int, initialMax int, tabstop int, wrapSignWidth int) []wrappedLine {
lines := []wrappedLine{}
width := 0
line := ""
gr := uniseg.NewGraphemes(input)
max := initialMax
for gr.Next() {
rs := gr.Runes()
str := string(rs)
var w int
if len(rs) == 1 && rs[0] == '\t' {
w = tabstop - (prefixLength+width)%tabstop
str = repeat(' ', w)
} else if rs[0] == '\r' {
w++
} else {
w = uniseg.StringWidth(str)
}
width += w
if prefixLength+width <= max {
line += str
} else {
lines = append(lines, wrappedLine{string(line), width - w})
line = str
prefixLength = 0
width = w
max = initialMax - wrapSignWidth
}
}
lines = append(lines, wrappedLine{string(line), width})
return lines
}
func (w *LightWindow) fill(str string, resetCode string) FillReturn { func (w *LightWindow) fill(str string, resetCode string) FillReturn {
allLines := strings.Split(str, "\n") allLines := strings.Split(str, "\n")
for i, line := range allLines { for i, line := range allLines {
lines := wrapLine(line, w.posx, w.width, w.tabstop, w.wrapSignWidth) lines := WrapLine(line, w.posx, w.width, w.tabstop, w.wrapSignWidth)
for j, wl := range lines { for j, wl := range lines {
w.stderrInternal(wl.text, false, resetCode) if w.posx < w.width {
w.posx += wl.displayWidth w.stderrInternal(wl.Text, false, resetCode)
w.posx += wl.DisplayWidth
}
// Wrap line // Wrap line
if j < len(lines)-1 || i < len(allLines)-1 { if j < len(lines)-1 || i < len(allLines)-1 {
@@ -1432,7 +1464,7 @@ func (w *LightWindow) fill(str string, resetCode string) FillReturn {
func (w *LightWindow) setBg() string { func (w *LightWindow) setBg() string {
if w.bg != colDefault { if w.bg != colDefault {
_, code := w.csiColor(colDefault, w.bg, AttrRegular) _, code := w.csiColor(colDefault, w.bg, colDefault, AttrRegular)
return code return code
} }
// Should clear dim attribute after ␍ in the preview window // Should clear dim attribute after ␍ in the preview window
@@ -1454,7 +1486,7 @@ func (w *LightWindow) Fill(text string) FillReturn {
return w.fill(text, code) return w.fill(text, code)
} }
func (w *LightWindow) CFill(fg Color, bg Color, attr Attr, text string) FillReturn { func (w *LightWindow) CFill(fg Color, bg Color, ul Color, attr Attr, text string) FillReturn {
w.Move(w.posy, w.posx) w.Move(w.posy, w.posx)
if fg == colDefault { if fg == colDefault {
fg = w.fg fg = w.fg
@@ -1462,7 +1494,7 @@ func (w *LightWindow) CFill(fg Color, bg Color, attr Attr, text string) FillRetu
if bg == colDefault { if bg == colDefault {
bg = w.bg bg = w.bg
} }
if hasColors, resetCode := w.csiColor(fg, bg, attr); hasColors { if hasColors, resetCode := w.csiColor(fg, bg, ul, attr); hasColors {
defer w.csi("0m") defer w.csi("0m")
return w.fill(text, resetCode) return w.fill(text, resetCode)
} }
+18 -1
View File
@@ -15,10 +15,27 @@ func TestLightRenderer(t *testing.T) {
light_renderer := renderer.(*LightRenderer) light_renderer := renderer.(*LightRenderer)
go func() {
for {
light_renderer.mutex.Lock()
ready := light_renderer.cancel != nil
light_renderer.mutex.Unlock()
if ready {
light_renderer.CancelGetChar()
break
}
}
}()
event := light_renderer.GetChar(true)
if event.Type != Invalid {
t.Error("Not cancelled")
}
assertCharSequence := func(sequence string, name string) { assertCharSequence := func(sequence string, name string) {
bytes := []byte(sequence) bytes := []byte(sequence)
light_renderer.buffer = bytes light_renderer.buffer = bytes
event := light_renderer.GetChar() event := light_renderer.GetChar(true)
if event.KeyName() != name { if event.KeyName() != name {
t.Errorf( t.Errorf(
"sequence: %q | %v | '%s' (%s) != %s", "sequence: %q | %v | '%s' (%s) != %s",
+55 -8
View File
@@ -99,7 +99,7 @@ func (r *LightRenderer) findOffset() (row int, col int) {
var err error var err error
bytes := []byte{} bytes := []byte{}
for tries := range offsetPollTries { for tries := range offsetPollTries {
bytes, err = r.getBytesInternal(bytes, tries > 0) bytes, _, err = r.getBytesInternal(false, bytes, tries > 0)
if err != nil { if err != nil {
return -1, -1 return -1, -1
} }
@@ -114,15 +114,62 @@ func (r *LightRenderer) findOffset() (row int, col int) {
return -1, -1 return -1, -1
} }
func (r *LightRenderer) getch(nonblock bool) (int, bool) { func (r *LightRenderer) getch(cancellable bool, nonblock bool) (int, getCharResult) {
b := make([]byte, 1)
fd := r.fd() fd := r.fd()
util.SetNonblock(r.ttyin, nonblock) getter := func() (int, getCharResult) {
_, err := util.Read(fd, b) b := make([]byte, 1)
if err != nil { util.SetNonblock(r.ttyin, nonblock)
return 0, false _, err := util.Read(fd, b)
if err != nil {
return 0, getCharError
}
return int(b[0]), getCharSuccess
} }
return int(b[0]), true if nonblock || !cancellable {
return getter()
}
rpipe, wpipe, err := os.Pipe()
if err != nil {
// Fallback to blocking read without cancellation
return getter()
}
r.setCancel(func() {
wpipe.Write([]byte{0})
})
defer func() {
r.setCancel(nil)
rpipe.Close()
wpipe.Close()
}()
cancelFd := int(rpipe.Fd())
for range maxSelectTries {
var rfds unix.FdSet
limit := len(rfds.Bits) * unix.NFDBITS
if fd >= limit || cancelFd >= limit {
return getter()
}
rfds.Set(fd)
rfds.Set(cancelFd)
_, err := unix.Select(max(fd, cancelFd)+1, &rfds, nil, nil, nil)
if err != nil {
if err == syscall.EINTR {
continue
}
return 0, getCharError
}
if rfds.IsSet(cancelFd) {
return 0, getCharCancelled
}
if rfds.IsSet(fd) {
return getter()
}
}
return 0, getCharError
} }
func (r *LightRenderer) Size() TermSize { func (r *LightRenderer) Size() TermSize {
+27 -10
View File
@@ -151,16 +151,33 @@ func (r *LightRenderer) findOffset() (row int, col int) {
return int(bufferInfo.CursorPosition.Y), int(bufferInfo.CursorPosition.X) return int(bufferInfo.CursorPosition.Y), int(bufferInfo.CursorPosition.X)
} }
func (r *LightRenderer) getch(nonblock bool) (int, bool) { func (r *LightRenderer) getch(cancellable bool, nonblock bool) (int, getCharResult) {
if nonblock { if !nonblock && !cancellable {
select {
case bc := <-r.ttyinChannel:
return int(bc), true
case <-time.After(timeoutInterval * time.Millisecond):
return 0, false
}
} else {
bc := <-r.ttyinChannel bc := <-r.ttyinChannel
return int(bc), true return int(bc), getCharSuccess
}
var timeout <-chan time.Time
if nonblock {
timeout = time.After(timeoutInterval * time.Millisecond)
}
var cancel chan struct{}
if cancellable {
cancel = make(chan struct{})
r.setCancel(func() {
close(cancel)
})
defer r.setCancel(nil)
}
select {
case bc := <-r.ttyinChannel:
return int(bc), getCharSuccess
case <-cancel:
return 0, getCharCancelled
case <-timeout:
// NOTE: not really an error
return 0, getCharError
} }
} }
+92 -56
View File
@@ -5,6 +5,7 @@ package tui
import ( import (
"os" "os"
"regexp" "regexp"
"strings"
"time" "time"
"github.com/gdamore/tcell/v2" "github.com/gdamore/tcell/v2"
@@ -53,6 +54,7 @@ type TcellWindow struct {
showCursor bool showCursor bool
wrapSign string wrapSign string
wrapSignWidth int wrapSignWidth int
tabstop int
} }
func (w *TcellWindow) Top() int { func (w *TcellWindow) Top() int {
@@ -246,7 +248,7 @@ func (r *FullscreenRenderer) Size() TermSize {
return TermSize{lines, cols, 0, 0} return TermSize{lines, cols, 0, 0}
} }
func (r *FullscreenRenderer) GetChar() Event { func (r *FullscreenRenderer) GetChar(cancellable bool) Event {
ev := _screen.PollEvent() ev := _screen.PollEvent()
switch ev := ev.(type) { switch ev := ev.(type) {
case *tcell.EventPaste: case *tcell.EventPaste:
@@ -703,6 +705,10 @@ func (r *FullscreenRenderer) GetChar() Event {
return Event{Invalid, 0, nil} return Event{Invalid, 0, nil}
} }
func (r *FullscreenRenderer) CancelGetChar() {
// TODO
}
func (r *FullscreenRenderer) Pause(clear bool) { func (r *FullscreenRenderer) Pause(clear bool) {
if clear { if clear {
_screen.Suspend() _screen.Suspend()
@@ -729,8 +735,8 @@ func (r *FullscreenRenderer) RefreshWindows(windows []Window) {
} }
func (r *FullscreenRenderer) NewWindow(top int, left int, width int, height int, windowType WindowType, borderStyle BorderStyle, erase bool) Window { func (r *FullscreenRenderer) NewWindow(top int, left int, width int, height int, windowType WindowType, borderStyle BorderStyle, erase bool) Window {
width = util.Max(0, width) width = max(0, width)
height = util.Max(0, height) height = max(0, height)
normal := ColBorder normal := ColBorder
switch windowType { switch windowType {
case WindowList: case WindowList:
@@ -753,7 +759,8 @@ func (r *FullscreenRenderer) NewWindow(top int, left int, width int, height int,
height: height, height: height,
normal: normal, normal: normal,
borderStyle: borderStyle, borderStyle: borderStyle,
showCursor: r.showCursor} showCursor: r.showCursor,
tabstop: r.tabstop}
w.Erase() w.Erase()
return w return w
} }
@@ -821,6 +828,21 @@ func (w *TcellWindow) withUrl(style tcell.Style) tcell.Style {
return style return style
} }
func underlineStyleFromAttr(a Attr) tcell.UnderlineStyle {
switch a.UnderlineStyle() {
case UlStyleDouble:
return tcell.UnderlineStyleDouble
case UlStyleCurly:
return tcell.UnderlineStyleCurly
case UlStyleDotted:
return tcell.UnderlineStyleDotted
case UlStyleDashed:
return tcell.UnderlineStyleDashed
default:
return tcell.UnderlineStyleSolid
}
}
func (w *TcellWindow) printString(text string, pair ColorPair) { func (w *TcellWindow) printString(text string, pair ColorPair) {
lx := 0 lx := 0
a := pair.Attr() a := pair.Attr()
@@ -829,11 +851,18 @@ func (w *TcellWindow) printString(text string, pair ColorPair) {
if a&AttrClear == 0 { if a&AttrClear == 0 {
style = style. style = style.
Reverse(a&Attr(tcell.AttrReverse) != 0). Reverse(a&Attr(tcell.AttrReverse) != 0).
Underline(a&Attr(tcell.AttrUnderline) != 0).
StrikeThrough(a&Attr(tcell.AttrStrikeThrough) != 0). StrikeThrough(a&Attr(tcell.AttrStrikeThrough) != 0).
Italic(a&Attr(tcell.AttrItalic) != 0). Italic(a&Attr(tcell.AttrItalic) != 0).
Blink(a&Attr(tcell.AttrBlink) != 0). Blink(a&Attr(tcell.AttrBlink) != 0).
Dim(a&Attr(tcell.AttrDim) != 0) Dim(a&Attr(tcell.AttrDim) != 0)
if a&Attr(tcell.AttrUnderline) != 0 {
style = style.Underline(underlineStyleFromAttr(a))
if pair.Ul() != colDefault {
style = style.Underline(asTcellColor(pair.Ul()))
}
} else {
style = style.Underline(false)
}
} }
style = w.withUrl(style) style = w.withUrl(style)
@@ -868,10 +897,8 @@ func (w *TcellWindow) CPrint(pair ColorPair, text string) {
w.printString(text, pair) w.printString(text, pair)
} }
func (w *TcellWindow) fillString(text string, pair ColorPair) FillReturn { func (w *TcellWindow) pairStyle(pair ColorPair) tcell.Style {
lx := 0
a := pair.Attr() a := pair.Attr()
var style tcell.Style var style tcell.Style
if w.color { if w.color {
style = pair.style() style = pair.style()
@@ -883,64 +910,73 @@ func (w *TcellWindow) fillString(text string, pair ColorPair) FillReturn {
Bold(a&Attr(tcell.AttrBold) != 0 || a&BoldForce != 0). Bold(a&Attr(tcell.AttrBold) != 0 || a&BoldForce != 0).
Dim(a&Attr(tcell.AttrDim) != 0). Dim(a&Attr(tcell.AttrDim) != 0).
Reverse(a&Attr(tcell.AttrReverse) != 0). Reverse(a&Attr(tcell.AttrReverse) != 0).
Underline(a&Attr(tcell.AttrUnderline) != 0).
StrikeThrough(a&Attr(tcell.AttrStrikeThrough) != 0). StrikeThrough(a&Attr(tcell.AttrStrikeThrough) != 0).
Italic(a&Attr(tcell.AttrItalic) != 0) Italic(a&Attr(tcell.AttrItalic) != 0)
style = w.withUrl(style) if a&Attr(tcell.AttrUnderline) != 0 {
style = style.Underline(underlineStyleFromAttr(a))
if pair.Ul() != colDefault {
style = style.Underline(asTcellColor(pair.Ul()))
}
} else {
style = style.Underline(false)
}
return w.withUrl(style)
}
func (w *TcellWindow) renderGraphemes(text string, style tcell.Style) {
gr := uniseg.NewGraphemes(text) gr := uniseg.NewGraphemes(text)
Loop:
for gr.Next() { for gr.Next() {
st := style st := style
rs := gr.Runes() rs := gr.Runes()
if len(rs) == 1 { if len(rs) == 1 && rs[0] == '\r' {
r := rs[0] st = style.Dim(true)
switch r { rs[0] = '␍'
case '\r':
st = style.Dim(true)
rs[0] = '␍'
case '\n':
w.lastY++
w.lastX = 0
lx = 0
continue Loop
}
}
// word wrap:
xPos := w.left + w.lastX + lx
if xPos >= w.left+w.width {
w.lastY++
if w.lastY >= w.height {
return FillSuspend
}
w.lastX = 0
lx = 0
xPos = w.left
sign := w.wrapSign
if w.wrapSignWidth > w.width {
runes, _ := util.Truncate(sign, w.width)
sign = string(runes)
}
wgr := uniseg.NewGraphemes(sign)
for wgr.Next() {
rs := wgr.Runes()
_screen.SetContent(w.left+lx, w.top+w.lastY, rs[0], rs[1:], style.Dim(true))
lx += uniseg.StringWidth(string(rs))
}
xPos = w.left + lx
} }
xPos := w.left + w.lastX
yPos := w.top + w.lastY yPos := w.top + w.lastY
if yPos >= (w.top + w.height) { if xPos < (w.left+w.width) && yPos < (w.top+w.height) {
return FillSuspend _screen.SetContent(xPos, yPos, rs[0], rs[1:], st)
} }
w.lastX += util.StringWidth(string(rs))
_screen.SetContent(xPos, yPos, rs[0], rs[1:], st)
lx += util.StringWidth(string(rs))
} }
w.lastX += lx }
if w.lastX == w.width {
func (w *TcellWindow) renderWrapSign(style tcell.Style) {
sign := w.wrapSign
if w.wrapSignWidth > w.width {
runes, _ := util.Truncate(sign, w.width)
sign = string(runes)
}
gr := uniseg.NewGraphemes(sign)
for gr.Next() {
rs := gr.Runes()
_screen.SetContent(w.left+w.lastX, w.top+w.lastY, rs[0], rs[1:], style.Dim(true))
w.lastX += uniseg.StringWidth(string(rs))
}
}
func (w *TcellWindow) fillString(text string, pair ColorPair) FillReturn {
style := w.pairStyle(pair)
for i, segment := range strings.Split(text, "\n") {
for j, wl := range WrapLine(segment, w.lastX, w.width, w.tabstop, w.wrapSignWidth) {
if i > 0 || j > 0 {
w.lastY++
if w.lastY >= w.height {
return FillSuspend
}
w.lastX = 0
if j > 0 {
w.renderWrapSign(style)
}
}
if w.lastX < w.width {
w.renderGraphemes(wl.Text, style)
}
}
}
if w.lastX >= w.width {
w.lastY++ w.lastY++
w.lastX = 0 w.lastX = 0
return FillNextLine return FillNextLine
@@ -963,14 +999,14 @@ func (w *TcellWindow) LinkEnd() {
w.params = nil w.params = nil
} }
func (w *TcellWindow) CFill(fg Color, bg Color, a Attr, str string) FillReturn { func (w *TcellWindow) CFill(fg Color, bg Color, ul Color, a Attr, str string) FillReturn {
if fg == colDefault { if fg == colDefault {
fg = w.normal.Fg() fg = w.normal.Fg()
} }
if bg == colDefault { if bg == colDefault {
bg = w.normal.Bg() bg = w.normal.Bg()
} }
return w.fillString(str, NewColorPair(fg, bg, a)) return w.fillString(str, NewColorPair(fg, bg, a).WithUl(ul))
} }
func (w *TcellWindow) DrawBorder() { func (w *TcellWindow) DrawBorder() {
+6 -6
View File
@@ -253,7 +253,7 @@ func TestGetCharEventKey(t *testing.T) {
{giveKey{tcell.KeyPause, 0, tcell.ModNone}, wantKey{Invalid, 0, nil}}, // unhandled {giveKey{tcell.KeyPause, 0, tcell.ModNone}, wantKey{Invalid, 0, nil}}, // unhandled
} }
r := NewFullscreenRenderer(&ColorTheme{}, false, false) r := NewFullscreenRenderer(&ColorTheme{}, false, false, 8)
r.Init() r.Init()
// run and evaluate the tests // run and evaluate the tests
@@ -265,22 +265,22 @@ func TestGetCharEventKey(t *testing.T) {
t.Logf("giveEvent = %T{key: %v, ch: %q (%[3]v), mod: %#04b}\n", giveEvent, giveEvent.Key(), giveEvent.Rune(), giveEvent.Modifiers()) t.Logf("giveEvent = %T{key: %v, ch: %q (%[3]v), mod: %#04b}\n", giveEvent, giveEvent.Key(), giveEvent.Rune(), giveEvent.Modifiers())
// process the event in fzf and evaluate the test // process the event in fzf and evaluate the test
gotEvent := r.GetChar() gotEvent := r.GetChar(true)
// skip Resize events, those are sometimes put in the buffer outside of this test // skip Resize events, those are sometimes put in the buffer outside of this test
if initialResizeAsInvalid && gotEvent.Type == Invalid { if initialResizeAsInvalid && gotEvent.Type == Invalid {
t.Logf("Resize as Invalid swallowed") t.Logf("Resize as Invalid swallowed")
initialResizeAsInvalid = false initialResizeAsInvalid = false
gotEvent = r.GetChar() gotEvent = r.GetChar(true)
} }
if gotEvent.Type == Resize { if gotEvent.Type == Resize {
t.Logf("Resize swallowed") t.Logf("Resize swallowed")
gotEvent = r.GetChar() gotEvent = r.GetChar(true)
} }
t.Logf("wantEvent = %T{Type: %v, Char: %q (%[3]v)}\n", test.wantKey, test.wantKey.Type, test.wantKey.Char) t.Logf("wantEvent = %T{Type: %v, Char: %q (%[3]v)}\n", test.wantKey, test.wantKey.Type, test.wantKey.Char)
t.Logf("gotEvent = %T{Type: %v, Char: %q (%[3]v)}\n", gotEvent, gotEvent.Type, gotEvent.Char) t.Logf("gotEvent = %T{Type: %v, Char: %q (%[3]v)}\n", gotEvent, gotEvent.Type, gotEvent.Char)
assert(t, "r.GetChar().Type", gotEvent.Type, test.wantKey.Type) assert(t, "r.GetChar(true).Type", gotEvent.Type, test.wantKey.Type)
assert(t, "r.GetChar().Char", gotEvent.Char, test.wantKey.Char) assert(t, "r.GetChar(true).Char", gotEvent.Char, test.wantKey.Char)
} }
r.Close() r.Close()
+100 -9
View File
@@ -2,6 +2,7 @@ package tui
import ( import (
"strconv" "strconv"
"strings"
"time" "time"
"github.com/junegunn/fzf/src/util" "github.com/junegunn/fzf/src/util"
@@ -17,15 +18,34 @@ const (
BoldForce = Attr(1 << 10) BoldForce = Attr(1 << 10)
FullBg = Attr(1 << 11) FullBg = Attr(1 << 11)
Strip = Attr(1 << 12) Strip = Attr(1 << 12)
// Underline style stored in bits 13-15 (3 bits, values 0-4)
// Only meaningful when the Underline attribute bit is also set.
// 0 = solid (default)
UnderlineStyleShift = 13
UnderlineStyleMask = Attr(0b111 << UnderlineStyleShift)
UlStyleDouble = Attr(0b001 << UnderlineStyleShift)
UlStyleCurly = Attr(0b010 << UnderlineStyleShift)
UlStyleDotted = Attr(0b011 << UnderlineStyleShift)
UlStyleDashed = Attr(0b100 << UnderlineStyleShift)
) )
func (a Attr) UnderlineStyle() Attr {
return a & UnderlineStyleMask
}
func (a Attr) Merge(b Attr) Attr { func (a Attr) Merge(b Attr) Attr {
if b&AttrRegular > 0 { if b&AttrRegular > 0 {
// Only keep bold attribute set by the system // Only keep bold attribute set by the system
return (b &^ AttrRegular) | (a & BoldForce) return (b &^ AttrRegular) | (a & BoldForce)
} }
return (a &^ AttrRegular) | b merged := (a &^ AttrRegular) | b
// When b sets Underline, use b's underline style instead of OR'ing
if b&Underline > 0 {
merged = (merged &^ UnderlineStyleMask) | (b & UnderlineStyleMask)
}
return merged
} }
// Types of user action // Types of user action
@@ -352,6 +372,7 @@ const (
type ColorPair struct { type ColorPair struct {
fg Color fg Color
bg Color bg Color
ul Color
attr Attr attr Attr
} }
@@ -363,11 +384,11 @@ func HexToColor(rrggbb string) Color {
} }
func NewColorPair(fg Color, bg Color, attr Attr) ColorPair { func NewColorPair(fg Color, bg Color, attr Attr) ColorPair {
return ColorPair{fg, bg, attr} return ColorPair{fg, bg, colDefault, attr}
} }
func NoColorPair() ColorPair { func NoColorPair() ColorPair {
return ColorPair{-1, -1, 0} return ColorPair{-1, -1, -1, 0}
} }
func (p ColorPair) Fg() Color { func (p ColorPair) Fg() Color {
@@ -378,6 +399,16 @@ func (p ColorPair) Bg() Color {
return p.bg return p.bg
} }
func (p ColorPair) Ul() Color {
return p.ul
}
func (p ColorPair) WithUl(ul Color) ColorPair {
dup := p
dup.ul = ul
return dup
}
func (p ColorPair) Attr() Attr { func (p ColorPair) Attr() Attr {
return p.attr return p.attr
} }
@@ -404,6 +435,9 @@ func (p ColorPair) merge(other ColorPair, except Color) ColorPair {
if other.bg != except { if other.bg != except {
dup.bg = other.bg dup.bg = other.bg
} }
if other.ul != except {
dup.ul = other.ul
}
return dup return dup
} }
@@ -415,13 +449,13 @@ func (p ColorPair) WithAttr(attr Attr) ColorPair {
func (p ColorPair) WithFg(fg ColorAttr) ColorPair { func (p ColorPair) WithFg(fg ColorAttr) ColorPair {
dup := p dup := p
fgPair := ColorPair{fg.Color, colUndefined, fg.Attr} fgPair := ColorPair{fg.Color, colUndefined, colUndefined, fg.Attr}
return dup.Merge(fgPair) return dup.Merge(fgPair)
} }
func (p ColorPair) WithBg(bg ColorAttr) ColorPair { func (p ColorPair) WithBg(bg ColorAttr) ColorPair {
dup := p dup := p
bgPair := ColorPair{colUndefined, bg.Color, bg.Attr} bgPair := ColorPair{colUndefined, bg.Color, colUndefined, bg.Attr}
return dup.Merge(bgPair) return dup.Merge(bgPair)
} }
@@ -456,6 +490,7 @@ type ColorTheme struct {
PreviewBg ColorAttr PreviewBg ColorAttr
DarkBg ColorAttr DarkBg ColorAttr
Gutter ColorAttr Gutter ColorAttr
AltGutter ColorAttr
Prompt ColorAttr Prompt ColorAttr
InputBg ColorAttr InputBg ColorAttr
InputBorder ColorAttr InputBorder ColorAttr
@@ -748,7 +783,8 @@ type Renderer interface {
HideCursor() HideCursor()
ShowCursor() ShowCursor()
GetChar() Event GetChar(cancellable bool) Event
CancelGetChar()
Top() int Top() int
MaxX() int MaxX() int
@@ -781,7 +817,7 @@ type Window interface {
Print(text string) Print(text string)
CPrint(color ColorPair, text string) CPrint(color ColorPair, text string)
Fill(text string) FillReturn Fill(text string) FillReturn
CFill(fg Color, bg Color, attr Attr, text string) FillReturn CFill(fg Color, bg Color, ul Color, attr Attr, text string) FillReturn
LinkBegin(uri string, params string) LinkBegin(uri string, params string)
LinkEnd() LinkEnd()
Erase() Erase()
@@ -794,16 +830,18 @@ type FullscreenRenderer struct {
theme *ColorTheme theme *ColorTheme
mouse bool mouse bool
forceBlack bool forceBlack bool
tabstop int
prevDownTime time.Time prevDownTime time.Time
clicks [][2]int clicks [][2]int
showCursor bool showCursor bool
} }
func NewFullscreenRenderer(theme *ColorTheme, forceBlack bool, mouse bool) Renderer { func NewFullscreenRenderer(theme *ColorTheme, forceBlack bool, mouse bool, tabstop int) Renderer {
r := &FullscreenRenderer{ r := &FullscreenRenderer{
theme: theme, theme: theme,
mouse: mouse, mouse: mouse,
forceBlack: forceBlack, forceBlack: forceBlack,
tabstop: tabstop,
prevDownTime: time.Unix(0, 0), prevDownTime: time.Unix(0, 0),
clicks: [][2]int{}, clicks: [][2]int{},
showCursor: true} showCursor: true}
@@ -826,6 +864,8 @@ var (
ColCursor ColorPair ColCursor ColorPair
ColCursorEmpty ColorPair ColCursorEmpty ColorPair
ColCursorEmptyChar ColorPair ColCursorEmptyChar ColorPair
ColAltCursorEmpty ColorPair
ColAltCursorEmptyChar ColorPair
ColMarker ColorPair ColMarker ColorPair
ColSelected ColorPair ColSelected ColorPair
ColSelectedMatch ColorPair ColSelectedMatch ColorPair
@@ -891,6 +931,7 @@ func init() {
PreviewFg: defaultColor, PreviewFg: defaultColor,
PreviewBg: defaultColor, PreviewBg: defaultColor,
Gutter: undefined, Gutter: undefined,
AltGutter: undefined,
PreviewBorder: defaultColor, PreviewBorder: defaultColor,
PreviewScrollbar: defaultColor, PreviewScrollbar: defaultColor,
PreviewLabel: defaultColor, PreviewLabel: defaultColor,
@@ -943,6 +984,7 @@ func init() {
PreviewFg: undefined, PreviewFg: undefined,
PreviewBg: undefined, PreviewBg: undefined,
Gutter: undefined, Gutter: undefined,
AltGutter: undefined,
PreviewBorder: undefined, PreviewBorder: undefined,
PreviewScrollbar: undefined, PreviewScrollbar: undefined,
PreviewLabel: undefined, PreviewLabel: undefined,
@@ -991,6 +1033,7 @@ func init() {
PreviewFg: undefined, PreviewFg: undefined,
PreviewBg: undefined, PreviewBg: undefined,
Gutter: undefined, Gutter: undefined,
AltGutter: undefined,
PreviewBorder: undefined, PreviewBorder: undefined,
PreviewScrollbar: undefined, PreviewScrollbar: undefined,
PreviewLabel: undefined, PreviewLabel: undefined,
@@ -1041,6 +1084,7 @@ func init() {
PreviewFg: undefined, PreviewFg: undefined,
PreviewBg: undefined, PreviewBg: undefined,
Gutter: undefined, Gutter: undefined,
AltGutter: undefined,
PreviewBorder: undefined, PreviewBorder: undefined,
PreviewScrollbar: undefined, PreviewScrollbar: undefined,
PreviewLabel: undefined, PreviewLabel: undefined,
@@ -1091,6 +1135,7 @@ func init() {
PreviewFg: undefined, PreviewFg: undefined,
PreviewBg: undefined, PreviewBg: undefined,
Gutter: undefined, Gutter: undefined,
AltGutter: undefined,
PreviewBorder: undefined, PreviewBorder: undefined,
PreviewScrollbar: undefined, PreviewScrollbar: undefined,
PreviewLabel: undefined, PreviewLabel: undefined,
@@ -1208,6 +1253,7 @@ func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlac
gutter.Attr = Dim gutter.Attr = Dim
} }
theme.Gutter = o(theme.DarkBg, gutter) theme.Gutter = o(theme.DarkBg, gutter)
theme.AltGutter = o(theme.Gutter, theme.AltGutter)
theme.PreviewFg = o(theme.Fg, theme.PreviewFg) theme.PreviewFg = o(theme.Fg, theme.PreviewFg)
theme.PreviewBg = o(theme.Bg, theme.PreviewBg) theme.PreviewBg = o(theme.Bg, theme.PreviewBg)
theme.PreviewLabel = o(theme.BorderLabel, theme.PreviewLabel) theme.PreviewLabel = o(theme.BorderLabel, theme.PreviewLabel)
@@ -1261,7 +1307,7 @@ func initPalette(theme *ColorTheme) {
if fg.Color == colDefault && (fg.Attr&Reverse) > 0 { if fg.Color == colDefault && (fg.Attr&Reverse) > 0 {
bg.Color = colDefault bg.Color = colDefault
} }
return ColorPair{fg.Color, bg.Color, fg.Attr} return ColorPair{fg.Color, bg.Color, colDefault, fg.Attr}
} }
blank := theme.ListFg blank := theme.ListFg
blank.Attr = AttrRegular blank.Attr = AttrRegular
@@ -1277,6 +1323,8 @@ func initPalette(theme *ColorTheme) {
ColCursor = pair(theme.Cursor, theme.Gutter) ColCursor = pair(theme.Cursor, theme.Gutter)
ColCursorEmpty = pair(blank, theme.Gutter) ColCursorEmpty = pair(blank, theme.Gutter)
ColCursorEmptyChar = pair(theme.Gutter, theme.ListBg) ColCursorEmptyChar = pair(theme.Gutter, theme.ListBg)
ColAltCursorEmpty = pair(blank, theme.AltGutter)
ColAltCursorEmptyChar = pair(theme.AltGutter, theme.ListBg)
if theme.SelectedBg.Color != theme.ListBg.Color { if theme.SelectedBg.Color != theme.ListBg.Color {
ColMarker = pair(theme.Marker, theme.SelectedBg) ColMarker = pair(theme.Marker, theme.SelectedBg)
} else { } else {
@@ -1315,3 +1363,46 @@ func initPalette(theme *ColorTheme) {
func runeWidth(r rune) int { func runeWidth(r rune) int {
return uniseg.StringWidth(string(r)) return uniseg.StringWidth(string(r))
} }
// WrappedLine represents a single visual line after character-level wrapping.
type WrappedLine struct {
Text string
DisplayWidth int
}
// WrapLine splits a single line (no embedded \n) into visual lines
// that fit within initialMax columns. Character-level wrapping only.
func WrapLine(input string, prefixLength int, initialMax int, tabstop int, wrapSignWidth int) []WrappedLine {
lines := []WrappedLine{}
width := 0
line := ""
gr := uniseg.NewGraphemes(input)
maxWidth := initialMax
contMax := max(1, initialMax-wrapSignWidth)
for gr.Next() {
rs := gr.Runes()
str := string(rs)
var w int
if len(rs) == 1 && rs[0] == '\t' {
w = tabstop - (prefixLength+width)%tabstop
str = strings.Repeat(" ", w)
} else if rs[0] == '\r' {
w++
} else {
w = uniseg.StringWidth(str)
}
width += w
if prefixLength+width <= maxWidth {
line += str
} else {
lines = append(lines, WrappedLine{string(line), width - w})
line = str
prefixLength = 0
width = w
maxWidth = contMax
}
}
lines = append(lines, WrappedLine{string(line), width})
return lines
}
+40
View File
@@ -2,6 +2,46 @@ package tui
import "testing" import "testing"
func TestWrapLine(t *testing.T) {
// Basic wrapping
lines := WrapLine("hello world", 0, 7, 8, 2)
if len(lines) != 2 || lines[0].Text != "hello w" || lines[1].Text != "orld" {
t.Errorf("Basic wrap: %v", lines)
}
// Exact fit — no wrapping needed
lines = WrapLine("hello", 0, 5, 8, 2)
if len(lines) != 1 || lines[0].Text != "hello" || lines[0].DisplayWidth != 5 {
t.Errorf("Exact fit: %v", lines)
}
// With prefix length
lines = WrapLine("hello", 3, 5, 8, 2)
if len(lines) != 2 || lines[0].Text != "he" || lines[1].Text != "llo" {
t.Errorf("Prefix length: %v", lines)
}
// Empty string
lines = WrapLine("", 0, 10, 8, 2)
if len(lines) != 1 || lines[0].Text != "" || lines[0].DisplayWidth != 0 {
t.Errorf("Empty string: %v", lines)
}
// Continuation lines account for wrapSignWidth
lines = WrapLine("abcdefghij", 0, 5, 8, 2)
// First line: "abcde" (5 chars fit in width 5)
// Continuation max: 5-2=3, so "fgh" then "ij"
if len(lines) != 3 || lines[0].Text != "abcde" || lines[1].Text != "fgh" || lines[2].Text != "ij" {
t.Errorf("Continuation: %v", lines)
}
// Tab expansion
lines = WrapLine("\there", 0, 10, 4, 2)
if len(lines) != 1 || lines[0].DisplayWidth != 8 {
t.Errorf("Tab: %v", lines)
}
}
func TestHexToColor(t *testing.T) { func TestHexToColor(t *testing.T) {
assert := func(expr string, r, g, b int) { assert := func(expr string, r, g, b int) {
color := HexToColor(expr) color := HexToColor(expr)
+15 -2
View File
@@ -187,7 +187,7 @@ func (chars *Chars) TrailingWhitespaces() int {
func (chars *Chars) TrimTrailingWhitespaces(maxIndex int) { func (chars *Chars) TrimTrailingWhitespaces(maxIndex int) {
whitespaces := chars.TrailingWhitespaces() whitespaces := chars.TrailingWhitespaces()
end := len(chars.slice) - whitespaces end := len(chars.slice) - whitespaces
chars.slice = chars.slice[0:Max(end, maxIndex)] chars.slice = chars.slice[0:max(end, maxIndex)]
} }
func (chars *Chars) TrimSuffix(runes []rune) { func (chars *Chars) TrimSuffix(runes []rune) {
@@ -249,7 +249,7 @@ func (chars *Chars) Prepend(prefix string) {
} }
} }
func (chars *Chars) Lines(multiLine bool, maxLines int, wrapCols int, wrapSignWidth int, tabstop int) ([][]rune, bool) { func (chars *Chars) Lines(multiLine bool, maxLines int, wrapCols int, wrapSignWidth int, tabstop int, wrapWord bool) ([][]rune, bool) {
text := make([]rune, chars.Length()) text := make([]rune, chars.Length())
copy(text, chars.ToRunes()) copy(text, chars.ToRunes())
@@ -307,6 +307,19 @@ func (chars *Chars) Lines(multiLine bool, maxLines int, wrapCols int, wrapSignWi
if overflowIdx == 0 { if overflowIdx == 0 {
overflowIdx = 1 overflowIdx = 1
} }
if wrapWord {
// Find last space/tab at or before overflowIdx
breakIdx := -1
for k := overflowIdx; k > 0; k-- {
if line[k-1] == ' ' || line[k-1] == '\t' {
breakIdx = k
break
}
}
if breakIdx > 0 {
overflowIdx = breakIdx
}
}
if len(wrapped) >= maxLines { if len(wrapped) >= maxLines {
return wrapped, true return wrapped, true
} }
+49 -1
View File
@@ -51,7 +51,7 @@ func TestTrimLength(t *testing.T) {
func TestCharsLines(t *testing.T) { func TestCharsLines(t *testing.T) {
chars := ToChars([]byte("abcdef\n가나다\n\tdef")) chars := ToChars([]byte("abcdef\n가나다\n\tdef"))
check := func(multiLine bool, maxLines int, wrapCols int, wrapSignWidth int, tabstop int, expectedNumLines int, expectedOverflow bool) { check := func(multiLine bool, maxLines int, wrapCols int, wrapSignWidth int, tabstop int, expectedNumLines int, expectedOverflow bool) {
lines, overflow := chars.Lines(multiLine, maxLines, wrapCols, wrapSignWidth, tabstop) lines, overflow := chars.Lines(multiLine, maxLines, wrapCols, wrapSignWidth, tabstop, false)
fmt.Println(lines, overflow) fmt.Println(lines, overflow)
if len(lines) != expectedNumLines || overflow != expectedOverflow { if len(lines) != expectedNumLines || overflow != expectedOverflow {
t.Errorf("Invalid result: %d %v (expected %d %v)", len(lines), overflow, expectedNumLines, expectedOverflow) t.Errorf("Invalid result: %d %v (expected %d %v)", len(lines), overflow, expectedNumLines, expectedOverflow)
@@ -81,3 +81,51 @@ func TestCharsLines(t *testing.T) {
// With wrap sign (3 + 2) and no multi-line // With wrap sign (3 + 2) and no multi-line
check(false, 100, 3, 2, 1, 13, false) check(false, 100, 3, 2, 1, 13, false)
} }
func TestCharsLinesWrapWord(t *testing.T) {
// "hello world foo bar" with width 12 should break at word boundaries
chars := ToChars([]byte("hello world foo bar"))
lines, overflow := chars.Lines(false, 100, 12, 0, 8, true)
// "hello world " (12) | "foo bar" (7)
if len(lines) != 2 || overflow {
t.Errorf("Expected 2 lines, got %d (overflow: %v): %v", len(lines), overflow, lines)
}
if string(lines[0]) != "hello world " {
t.Errorf("Expected first line 'hello world ', got %q", string(lines[0]))
}
if string(lines[1]) != "foo bar" {
t.Errorf("Expected second line 'foo bar', got %q", string(lines[1]))
}
// No word boundary: a single long word falls back to character wrap
chars2 := ToChars([]byte("abcdefghijklmnop"))
lines2, _ := chars2.Lines(false, 100, 10, 0, 8, true)
if len(lines2) != 2 {
t.Errorf("Expected 2 lines for long word, got %d: %v", len(lines2), lines2)
}
if string(lines2[0]) != "abcdefghij" {
t.Errorf("Expected first line 'abcdefghij', got %q", string(lines2[0]))
}
// Tab as word boundary
chars3 := ToChars([]byte("hello\tworld"))
lines3, _ := chars3.Lines(false, 100, 7, 0, 8, true)
// "hello\t" should break at tab (width of tab at pos 5 with tabstop 8 = 3, total width = 8 > 7)
// Actually RunesWidth: 'h'=1,'e'=1,'l'=1,'l'=1,'o'=1,'\t'=3 = 8 > 7, overflowIdx=5
// Then word-wrap scans back and finds no space/tab before idx 5 (tab IS at idx 5 but we check line[k-1])
// Wait - let me think: overflowIdx=5, we check k=5 -> line[4]='o', k=4 -> line[3]='l'... no space/tab found
// Falls back to character wrap: "hello" | "\tworld"
if len(lines3) < 2 {
t.Errorf("Expected at least 2 lines for tab test, got %d: %v", len(lines3), lines3)
}
// wrapWord=false still character-wraps
chars4 := ToChars([]byte("hello world"))
lines4, _ := chars4.Lines(false, 100, 8, 0, 8, false)
if len(lines4) != 2 {
t.Errorf("Expected 2 lines with wrapWord=false, got %d: %v", len(lines4), lines4)
}
if string(lines4[0]) != "hello wo" {
t.Errorf("Expected first line 'hello wo', got %q", string(lines4[0]))
}
}
+10 -63
View File
@@ -1,11 +1,11 @@
package util package util
import ( import (
"cmp"
"math" "math"
"os" "os"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/mattn/go-isatty" "github.com/mattn/go-isatty"
"github.com/rivo/uniseg" "github.com/rivo/uniseg"
@@ -18,8 +18,13 @@ func StringWidth(s string) int {
// RunesWidth returns runes width // RunesWidth returns runes width
func RunesWidth(runes []rune, prefixWidth int, tabstop int, limit int) (int, int) { func RunesWidth(runes []rune, prefixWidth int, tabstop int, limit int) (int, int) {
return StringsWidth(string(runes), prefixWidth, tabstop, limit)
}
// StringsWidth returns the width of the string
func StringsWidth(str string, prefixWidth int, tabstop int, limit int) (int, int) {
width := 0 width := 0
gr := uniseg.NewGraphemes(string(runes)) gr := uniseg.NewGraphemes(str)
idx := 0 idx := 0
for gr.Next() { for gr.Next() {
rs := gr.Runes() rs := gr.Runes()
@@ -55,54 +60,8 @@ func Truncate(input string, limit int) ([]rune, int) {
return runes, width return runes, width
} }
// Max returns the largest integer func Constrain[T cmp.Ordered](val, minimum, maximum T) T {
func Max(first int, second int) int { return max(min(val, maximum), minimum)
if first >= second {
return first
}
return second
}
// Max16 returns the largest integer
func Max16(first int16, second int16) int16 {
if first >= second {
return first
}
return second
}
// Max32 returns the largest 32-bit integer
func Max32(first int32, second int32) int32 {
if first > second {
return first
}
return second
}
// Min returns the smallest integer
func Min(first int, second int) int {
if first <= second {
return first
}
return second
}
// Min32 returns the smallest 32-bit integer
func Min32(first int32, second int32) int32 {
if first <= second {
return first
}
return second
}
// Constrain32 limits the given 32-bit integer with the upper and lower bounds
func Constrain32(val int32, min int32, max int32) int32 {
return Max32(Min32(val, max), min)
}
// Constrain limits the given integer with the upper and lower bounds
func Constrain(val int, min int, max int) int {
return Max(Min(val, max), min)
} }
func AsUint16(val int) uint16 { func AsUint16(val int) uint16 {
@@ -114,18 +73,6 @@ func AsUint16(val int) uint16 {
return uint16(val) return uint16(val)
} }
// DurWithin limits the given time.Duration with the upper and lower bounds
func DurWithin(
val time.Duration, min time.Duration, max time.Duration) time.Duration {
if val < min {
return min
}
if val > max {
return max
}
return val
}
// IsTty returns true if the file is a terminal // IsTty returns true if the file is a terminal
func IsTty(file *os.File) bool { func IsTty(file *os.File) bool {
fd := file.Fd() fd := file.Fd()
@@ -197,7 +144,7 @@ func CompareVersions(v1, v2 string) int {
return n return n
} }
for i := 0; i < Max(len(parts1), len(parts2)); i++ { for i := 0; i < max(len(parts1), len(parts2)); i++ {
var p1, p2 int var p1, p2 int
if i < len(parts1) { if i < len(parts1) {
p1 = atoi(parts1[i]) p1 = atoi(parts1[i])
-92
View File
@@ -4,72 +4,8 @@ import (
"math" "math"
"strings" "strings"
"testing" "testing"
"time"
) )
func TestMax(t *testing.T) {
if Max(10, 1) != 10 {
t.Error("Expected", 10)
}
if Max(-2, 5) != 5 {
t.Error("Expected", 5)
}
}
func TestMax16(t *testing.T) {
if Max16(10, 1) != 10 {
t.Error("Expected", 10)
}
if Max16(-2, 5) != 5 {
t.Error("Expected", 5)
}
if Max16(math.MaxInt16, 0) != math.MaxInt16 {
t.Error("Expected", math.MaxInt16)
}
if Max16(0, math.MinInt16) != 0 {
t.Error("Expected", 0)
}
}
func TestMax32(t *testing.T) {
if Max32(10, 1) != 10 {
t.Error("Expected", 10)
}
if Max32(-2, 5) != 5 {
t.Error("Expected", 5)
}
if Max32(math.MaxInt32, 0) != math.MaxInt32 {
t.Error("Expected", math.MaxInt32)
}
if Max32(0, math.MinInt32) != 0 {
t.Error("Expected", 0)
}
}
func TestMin(t *testing.T) {
if Min(10, 1) != 1 {
t.Error("Expected", 1)
}
if Min(-2, 5) != -2 {
t.Error("Expected", -2)
}
}
func TestMin32(t *testing.T) {
if Min32(10, 1) != 1 {
t.Error("Expected", 1)
}
if Min32(-2, 5) != -2 {
t.Error("Expected", -2)
}
if Min32(math.MaxInt32, 0) != 0 {
t.Error("Expected", 0)
}
if Min32(0, math.MinInt32) != math.MinInt32 {
t.Error("Expected", math.MinInt32)
}
}
func TestConstrain(t *testing.T) { func TestConstrain(t *testing.T) {
if Constrain(-3, -1, 3) != -1 { if Constrain(-3, -1, 3) != -1 {
t.Error("Expected", -1) t.Error("Expected", -1)
@@ -83,22 +19,6 @@ func TestConstrain(t *testing.T) {
} }
} }
func TestConstrain32(t *testing.T) {
if Constrain32(-3, -1, 3) != -1 {
t.Error("Expected", -1)
}
if Constrain32(2, -1, 3) != 2 {
t.Error("Expected", 2)
}
if Constrain32(5, -1, 3) != 3 {
t.Error("Expected", 3)
}
if Constrain32(0, math.MinInt32, math.MaxInt32) != 0 {
t.Error("Expected", 0)
}
}
func TestAsUint16(t *testing.T) { func TestAsUint16(t *testing.T) {
if AsUint16(5) != 5 { if AsUint16(5) != 5 {
t.Error("Expected", 5) t.Error("Expected", 5)
@@ -120,18 +40,6 @@ func TestAsUint16(t *testing.T) {
} }
} }
func TestDurWithIn(t *testing.T) {
if DurWithin(time.Duration(5), time.Duration(1), time.Duration(8)) != time.Duration(5) {
t.Error("Expected", time.Duration(0))
}
if DurWithin(time.Duration(0)*time.Second, time.Second, time.Duration(3)*time.Second) != time.Second {
t.Error("Expected", time.Second)
}
if DurWithin(time.Duration(10)*time.Second, time.Duration(0), time.Second) != time.Second {
t.Error("Expected", time.Second)
}
}
func TestOnce(t *testing.T) { func TestOnce(t *testing.T) {
o := Once(false) o := Once(false)
if o() { if o() {
+17
View File
@@ -0,0 +1,17 @@
# Unset fzf variables
set -e FZF_DEFAULT_COMMAND FZF_DEFAULT_OPTS FZF_DEFAULT_OPTS_FILE FZF_TMUX FZF_TMUX_OPTS
set -e FZF_CTRL_T_COMMAND FZF_CTRL_T_OPTS FZF_ALT_C_COMMAND FZF_ALT_C_OPTS FZF_CTRL_R_OPTS
set -e FZF_API_KEY
# Unset completion-specific variables
set -e FZF_COMPLETION_TRIGGER FZF_COMPLETION_OPTS
set -gx FZF_DEFAULT_OPTS "--no-scrollbar --pointer '>' --marker '>'"
set -gx FZF_COMPLETION_TRIGGER '++'
set -gx fish_history fzf_test
# Add fzf to PATH
fish_add_path <%= BASE %>/bin
# Source key bindings and completion
source "<%= BASE %>/shell/key-bindings.fish"
source "<%= BASE %>/shell/completion.fish"
+12 -1
View File
@@ -11,6 +11,7 @@ require 'net/http'
require 'json' require 'json'
TEMPLATE = File.read(File.expand_path('common.sh', __dir__)) TEMPLATE = File.read(File.expand_path('common.sh', __dir__))
FISH_TEMPLATE = File.read(File.expand_path('common.fish', __dir__))
UNSETS = %w[ UNSETS = %w[
FZF_DEFAULT_COMMAND FZF_DEFAULT_OPTS FZF_DEFAULT_COMMAND FZF_DEFAULT_OPTS
FZF_TMUX FZF_TMUX_OPTS FZF_TMUX FZF_TMUX_OPTS
@@ -66,7 +67,16 @@ class Shell
end end
def fish def fish
"unset #{UNSETS.join(' ')}; rm -f ~/.local/share/fish/fzf_test_history; FZF_DEFAULT_OPTS=\"--no-scrollbar --pointer '>' --marker '>'\" fish_history=fzf_test fish" @fish ||=
begin
confdir = '/tmp/fzf-fish'
FileUtils.rm_rf(confdir)
FileUtils.mkdir_p("#{confdir}/fish/conf.d")
File.open("#{confdir}/fish/conf.d/fzf.fish", 'w') do |f|
f.puts ERB.new(FISH_TEMPLATE).result(binding)
end
"rm -f ~/.local/share/fish/fzf_test_history; XDG_CONFIG_HOME=#{confdir} fish"
end
end end
end end
end end
@@ -153,6 +163,7 @@ class Tmux
self.until(true) do |lines| self.until(true) do |lines|
message = "Prepare[#{tries}]" message = "Prepare[#{tries}]"
send_keys ' ', 'C-u', :Enter, message, :Left, :Right send_keys ' ', 'C-u', :Enter, message, :Left, :Right
sleep(0.15)
lines[-1] == message lines[-1] == message
end end
rescue Minitest::Assertion rescue Minitest::Assertion
+48 -9
View File
@@ -1191,7 +1191,7 @@ class TestCore < TestInteractive
end end
def test_freeze_left_keep_right def test_freeze_left_keep_right
tmux.send_keys %[seq 10000 | #{FZF} --read0 --delimiter "\n" --freeze-left 3 --keep-right --ellipsis XX --no-multi-line --bind space:toggle-multi-line], :Enter tmux.send_keys %(seq 10000 | #{FZF} --read0 --delimiter "\n" --freeze-left 3 --keep-right --ellipsis XX --no-multi-line --bind space:toggle-multi-line), :Enter
tmux.until { |lines| assert_match(/^> 1␊2␊3XX.*10000␊$/, lines[-3]) } tmux.until { |lines| assert_match(/^> 1␊2␊3XX.*10000␊$/, lines[-3]) }
tmux.send_keys '5' tmux.send_keys '5'
tmux.until { |lines| assert_match(/^> 1␊2␊3␊4␊5␊.*XX$/, lines[-3]) } tmux.until { |lines| assert_match(/^> 1␊2␊3␊4␊5␊.*XX$/, lines[-3]) }
@@ -1202,21 +1202,21 @@ class TestCore < TestInteractive
end end
def test_freeze_left_and_right def test_freeze_left_and_right
tmux.send_keys %[seq 10000 | tr "\n" ' ' | #{FZF} --freeze-left 3 --freeze-right 3 --ellipsis XX], :Enter tmux.send_keys %(seq 10000 | tr "\n" ' ' | #{FZF} --freeze-left 3 --freeze-right 3 --ellipsis XX), :Enter
tmux.until { |lines| assert_match(/XX9998 9999 10000$/, lines[-3]) } tmux.until { |lines| assert_match(/XX9998 9999 10000$/, lines[-3]) }
tmux.send_keys "'1000" tmux.send_keys "'1000"
tmux.until { |lines| assert_match(/^> 1 2 3XX.*XX9998 9999 10000$/,lines[-3]) } tmux.until { |lines| assert_match(/^> 1 2 3XX.*XX9998 9999 10000$/, lines[-3]) }
end end
def test_freeze_left_and_right_delimiter def test_freeze_left_and_right_delimiter
tmux.send_keys %[seq 10000 | tr "\n" ' ' | sed 's/ / , /g' | #{FZF} --freeze-left 3 --freeze-right 3 --ellipsis XX --delimiter ' , '], :Enter tmux.send_keys %(seq 10000 | tr "\n" ' ' | sed 's/ / , /g' | #{FZF} --freeze-left 3 --freeze-right 3 --ellipsis XX --delimiter ' , '), :Enter
tmux.until { |lines| assert_match(/XX, 9999 , 10000 ,$/, lines[-3]) } tmux.until { |lines| assert_match(/XX, 9999 , 10000 ,$/, lines[-3]) }
tmux.send_keys "'1000" tmux.send_keys "'1000"
tmux.until { |lines| assert_match(/^> 1 , 2 , 3 ,XX.*XX, 9999 , 10000 ,$/,lines[-3]) } tmux.until { |lines| assert_match(/^> 1 , 2 , 3 ,XX.*XX, 9999 , 10000 ,$/, lines[-3]) }
end end
def test_freeze_right_exceed_range def test_freeze_right_exceed_range
tmux.send_keys %[seq 10000 | tr "\n" ' ' | #{FZF} --freeze-right 100000 --ellipsis XX], :Enter tmux.send_keys %(seq 10000 | tr "\n" ' ' | #{FZF} --freeze-right 100000 --ellipsis XX), :Enter
['', "'1000"].each do |query| ['', "'1000"].each do |query|
tmux.send_keys query tmux.send_keys query
tmux.until { |lines| assert lines.any_include?("> #{query}".strip) } tmux.until { |lines| assert lines.any_include?("> #{query}".strip) }
@@ -1228,13 +1228,23 @@ class TestCore < TestInteractive
end end
def test_freeze_right_exceed_range_with_freeze_left def test_freeze_right_exceed_range_with_freeze_left
tmux.send_keys %[seq 10000 | tr "\n" ' ' | #{FZF} --freeze-left 3 --freeze-right 100000 --ellipsis XX], :Enter tmux.send_keys %(seq 10000 | tr "\n" ' ' | #{FZF} --freeze-left 3 --freeze-right 100000 --ellipsis XX), :Enter
tmux.until do |lines| tmux.until do |lines|
assert_match(/^> 1 2 3XX.*9998 9999 10000$/, lines[-3]) assert_match(/^> 1 2 3XX.*9998 9999 10000$/, lines[-3])
assert_equal(1, lines[-3].scan('XX').size) assert_equal(1, lines[-3].scan('XX').size)
end end
end end
def test_freeze_right_with_ellipsis_and_scrolling
tmux.send_keys "{ seq 6; ruby -e 'print \"g\"*1000, \"\\n\"'; seq 8 100; } | #{FZF} --ellipsis='777' --freeze-right 1 --scroll-off 0 --bind a:offset-up", :Enter
tmux.until { |lines| assert_equal ' 100/100', lines[-2] }
tmux.send_keys(*Array.new(6) { :a })
tmux.until do |lines|
assert_match(/> 777g+$/, lines[-3])
assert_equal(1, lines.count { |l| l.end_with?('g') })
end
end
def test_backward_eof def test_backward_eof
tmux.send_keys "echo foo | #{FZF} --bind 'backward-eof:reload(seq 100)'", :Enter tmux.send_keys "echo foo | #{FZF} --bind 'backward-eof:reload(seq 100)'", :Enter
tmux.until { |lines| lines.item_count == 1 && lines.match_count == 1 } tmux.until { |lines| lines.item_count == 1 && lines.match_count == 1 }
@@ -1578,14 +1588,16 @@ class TestCore < TestInteractive
end end
def test_track_action def test_track_action
tmux.send_keys "seq 1000 | #{FZF} --query 555 --bind t:track", :Enter tmux.send_keys "seq 1000 | #{FZF} --pointer x --query 555 --bind t:track,T:up+track", :Enter
tmux.until do |lines| tmux.until do |lines|
assert_equal 1, lines.match_count assert_equal 1, lines.match_count
assert_includes lines, 'x 555'
assert_includes lines, '> 555' assert_includes lines, '> 555'
end end
tmux.send_keys :BSpace tmux.send_keys :BSpace
tmux.until do |lines| tmux.until do |lines|
assert_equal 28, lines.match_count assert_equal 28, lines.match_count
assert_includes lines, 'x 55'
assert_includes lines, '> 55' assert_includes lines, '> 55'
end end
tmux.send_keys :t tmux.send_keys :t
@@ -1595,7 +1607,8 @@ class TestCore < TestInteractive
tmux.send_keys :BSpace tmux.send_keys :BSpace
tmux.until do |lines| tmux.until do |lines|
assert_equal 271, lines.match_count assert_equal 271, lines.match_count
assert_includes lines, '> 55' assert_includes lines, 'x 55'
assert_includes lines, '> 5'
end end
# Automatically disabled when the tracking item is no longer visible # Automatically disabled when the tracking item is no longer visible
@@ -1607,16 +1620,33 @@ class TestCore < TestInteractive
tmux.send_keys :BSpace tmux.send_keys :BSpace
tmux.until do |lines| tmux.until do |lines|
assert_equal 271, lines.match_count assert_equal 271, lines.match_count
assert_includes lines, 'x 52'
assert_includes lines, '> 5' assert_includes lines, '> 5'
end end
tmux.send_keys :t tmux.send_keys :t
tmux.until do |lines| tmux.until do |lines|
assert_includes lines[-2], '+t' assert_includes lines[-2], '+t'
end end
# Automatically disabled when the focus has moved
tmux.send_keys :Up tmux.send_keys :Up
tmux.until do |lines| tmux.until do |lines|
assert_includes lines, 'x 53'
refute_includes lines[-2], '+t' refute_includes lines[-2], '+t'
end end
# Should work even when combined with a focus moving actions
tmux.send_keys 'T'
tmux.until do |lines|
assert_includes lines, 'x 54'
assert_includes lines[-2], '+t'
end
tmux.send_keys 'T'
tmux.until do |lines|
assert_includes lines, 'x 55'
assert_includes lines[-2], '+t'
end
end end
def test_one_and_zero def test_one_and_zero
@@ -2145,4 +2175,13 @@ class TestCore < TestInteractive
assert_equal 1, it.match_count assert_equal 1, it.match_count
end end
end end
def test_zero_width_characters
tmux.send_keys %(for i in {1..1000}; do string+="a̱$i"; printf '\\e[43m%s\\e[0m\\n' "$string"; done | #{FZF} --ansi --query a500 --ellipsis XX), :Enter
tmux.until do |lines|
assert_equal 981, lines.match_count
assert_match(/^> XX.*a̱500/, lines[-3])
assert(lines.reverse.drop(5).all? { it.match?(/^ XX.*a̱500.*XX/) })
end
end
end end
+14
View File
@@ -312,4 +312,18 @@ class TestFilter < TestBase
assert_equal expected, result assert_equal expected, result
end end
end end
def test_accept_nth
# Single field selection
assert_equal 'three', `echo 'one two three' | #{FZF} -d' ' --with-nth 1 --accept-nth -1 -f one`.chomp
# Multiple field selection
writelines(['ID001:John:Developer', 'ID002:Jane:Manager', 'ID003:Bob:Designer'])
assert_equal 'ID001', `#{FZF} -d: --with-nth 2 --accept-nth 1 -f John < #{tempname}`.chomp
assert_equal 'ID002:Manager', `#{FZF} -d: --with-nth 2 --accept-nth 1,3 -f Jane < #{tempname}`.chomp
# Test with different delimiters
writelines(['emp001 Alice Engineering', 'emp002 Bob Marketing'])
assert_equal 'emp001', `#{FZF} -d' ' --with-nth 2 --accept-nth 1 -f Alice < #{tempname}`.chomp
end
end end
+38 -1
View File
@@ -383,6 +383,16 @@ class TestPreview < TestInteractive
end end
end 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 }
tmux.until do |lines|
idx = lines.rindex { it.include?('│ 10 │') }
assert_includes lines[idx + 1], '│ ↳ │'
assert_includes lines[idx + 2], '│ ↳ │'
end
end
def test_close def test_close
tmux.send_keys "seq 100 | #{FZF} --preview 'echo foo' --bind ctrl-c:close", :Enter tmux.send_keys "seq 100 | #{FZF} --preview 'echo foo' --bind ctrl-c:close", :Enter
tmux.until { |lines| assert_equal 100, lines.match_count } tmux.until { |lines| assert_equal 100, lines.match_count }
@@ -541,7 +551,7 @@ class TestPreview < TestInteractive
tmux.send_keys "seq 10 | #{FZF} --preview-border rounded --preview-window '~5,2,+0,<100000(~0,+100,wrap,noinfo)' --preview 'seq 1000'", :Enter tmux.send_keys "seq 10 | #{FZF} --preview-border rounded --preview-window '~5,2,+0,<100000(~0,+100,wrap,noinfo)' --preview 'seq 1000'", :Enter
tmux.until { |lines| assert_equal 10, lines.match_count } tmux.until { |lines| assert_equal 10, lines.match_count }
tmux.until do |lines| tmux.until do |lines|
assert_equal ['╭────╮', '│ 10 │', '│ ↳ 0│', '│ 10 │', '│ ↳ 1│'], lines.take(5).map(&:strip) assert_equal ['╭────╮', '│ 10 │', '│ ↳ │', '│ 10 │', '│ ↳ │'], lines.take(5).map(&:strip)
end end
end end
@@ -572,4 +582,31 @@ class TestPreview < TestInteractive
assert_equal 1, lines.match_count assert_equal 1, lines.match_count
end end
end end
def test_preview_wrap_sign_between_ansi_fragments
tmux.send_keys %(seq 1 | #{FZF} --preview 'echo -e "\\x1b[33m1234567890 \\x1b[mhello"; echo -e "\\x1b[33m1234567890 \\x1b[mhello"' --preview-window 10,wrap-word), :Enter
tmux.until do |lines|
assert_equal 1, lines.match_count
assert_equal(2, lines.count { |line| line.include?('│ 1234567890 │') })
assert_equal(2, lines.count { |line| line.include?('│ ↳ hello │') })
end
end
def test_preview_wrap_sign_between_ansi_fragments_overflow
tmux.send_keys %(seq 1 | #{FZF} --preview 'echo -e "\\x1b[33m1234567890 \\x1b[mhello"; echo -e "\\x1b[33m1234567890 \\x1b[mhello"' --preview-window 2,wrap-word), :Enter
tmux.until do |lines|
assert_equal 1, lines.match_count
assert_equal(2, lines.count { |line| line.include?('│ 12 │') })
assert_equal(0, lines.count { |line| line.include?('│ h') })
end
end
def test_preview_wrap_sign_between_ansi_fragments_overflow2
tmux.send_keys %(seq 1 | #{FZF} --preview 'echo -e "\\x1b[33m1234567890 \\x1b[mhello"; echo -e "\\x1b[33m1234567890 \\x1b[mhello"' --preview-window 1,wrap-word), :Enter
tmux.until do |lines|
assert_equal 1, lines.match_count
assert_equal(2, lines.count { |line| line.include?('│ 1 │') })
assert_equal(0, lines.count { |line| line.include?('│ h') })
end
end
end end
+13 -3
View File
@@ -31,22 +31,32 @@ class TestServer < TestInteractive
end end
def test_listen_with_api_key def test_listen_with_api_key
post_uri = URI('http://localhost:6266') uri = URI('http://localhost:6266')
tmux.send_keys 'seq 10 | FZF_API_KEY=123abc fzf --listen 6266', :Enter tmux.send_keys 'seq 10 | FZF_API_KEY=123abc fzf --listen 6266', :Enter
tmux.until { |lines| assert_equal 10, lines.match_count } tmux.until { |lines| assert_equal 10, lines.match_count }
# Incorrect API Key # Incorrect API Key
[nil, { 'x-api-key' => '' }, { 'x-api-key' => '124abc' }].each do |headers| [nil, { 'x-api-key' => '' }, { 'x-api-key' => '124abc' }].each do |headers|
res = Net::HTTP.post(post_uri, 'change-query(yo)+reload(seq 100)+change-prompt:hundred> ', headers) res = Net::HTTP.post(uri, 'change-query(yo)+reload(seq 100)+change-prompt:hundred> ', headers)
assert_equal '401', res.code
assert_equal 'Unauthorized', res.message
assert_equal "invalid api key\n", res.body
res = Net::HTTP.get_response(uri, headers)
assert_equal '401', res.code assert_equal '401', res.code
assert_equal 'Unauthorized', res.message assert_equal 'Unauthorized', res.message
assert_equal "invalid api key\n", res.body assert_equal "invalid api key\n", res.body
end end
# Valid API Key # Valid API Key
[{ 'x-api-key' => '123abc' }, { 'X-API-Key' => '123abc' }].each do |headers| [{ 'x-api-key' => '123abc' }, { 'X-API-Key' => '123abc' }].each do |headers|
res = Net::HTTP.post(post_uri, 'change-query(yo)+reload(seq 100)+change-prompt:hundred> ', headers) res = Net::HTTP.post(uri, 'change-query(yo)+reload(seq 100)+change-prompt:hundred> ', headers)
assert_equal '200', res.code assert_equal '200', res.code
tmux.until { |lines| assert_equal 100, lines.item_count } tmux.until { |lines| assert_equal 100, lines.item_count }
tmux.until { |lines| assert_equal 'hundred> yo', lines[-1] } tmux.until { |lines| assert_equal 'hundred> yo', lines[-1] }
res = Net::HTTP.get_response(uri, headers)
assert_equal '200', res.code
assert_equal 'yo', JSON.parse(res.body, symbolize_names: true)[:query]
end end
end end
end end
+614 -76
View File
@@ -27,6 +27,10 @@ module TestShell
tmux.prepare tmux.prepare
end end
def trigger
'**'
end
def test_ctrl_t def test_ctrl_t
set_var('FZF_CTRL_T_COMMAND', 'seq 100') set_var('FZF_CTRL_T_COMMAND', 'seq 100')
@@ -134,7 +138,7 @@ module TestShell
tmux.send_keys 'C-r' tmux.send_keys 'C-r'
tmux.until { |lines| assert_equal '>', lines[-1] } tmux.until { |lines| assert_equal '>', lines[-1] }
tmux.send_keys 'foo bar' tmux.send_keys 'foo bar'
tmux.until { |lines| assert_includes lines[-4], '"foo' } unless shell == :zsh tmux.until { |lines| assert_includes lines[-4], '"foo' } if shell == :bash
tmux.until { |lines| assert lines[-3]&.match?(/bar"␊?/) } tmux.until { |lines| assert lines[-3]&.match?(/bar"␊?/) }
tmux.send_keys :Enter tmux.send_keys :Enter
tmux.until { |lines| assert lines[-1]&.match?(/bar"␊?/) } tmux.until { |lines| assert lines[-1]&.match?(/bar"␊?/) }
@@ -165,7 +169,11 @@ module CompletionTest
FileUtils.touch(File.expand_path(f)) FileUtils.touch(File.expand_path(f))
end end
tmux.prepare tmux.prepare
tmux.send_keys 'cat /tmp/fzf-test/10**', :Tab if shell == :fish
tmux.send_keys 'cat /tmp/fzf-test/10', 'C-t'
else
tmux.send_keys "cat /tmp/fzf-test/10#{trigger}", :Tab
end
tmux.until { |lines| assert_operator lines.match_count, :>, 0 } tmux.until { |lines| assert_operator lines.match_count, :>, 0 }
tmux.send_keys ' !d' tmux.send_keys ' !d'
tmux.until { |lines| assert_equal 2, lines.match_count } tmux.until { |lines| assert_equal 2, lines.match_count }
@@ -179,7 +187,11 @@ module CompletionTest
# ~USERNAME**<TAB> # ~USERNAME**<TAB>
user = `whoami`.chomp user = `whoami`.chomp
tmux.send_keys 'C-u' tmux.send_keys 'C-u'
tmux.send_keys "cat ~#{user}**", :Tab if shell == :fish
tmux.send_keys "cat ~#{user}", 'C-t'
else
tmux.send_keys "cat ~#{user}#{trigger}", :Tab
end
tmux.until { |lines| assert_operator lines.match_count, :>, 0 } tmux.until { |lines| assert_operator lines.match_count, :>, 0 }
tmux.send_keys "/#{user}" tmux.send_keys "/#{user}"
tmux.until { |lines| assert(lines.any? { |l| l.end_with?("/#{user}") }) } tmux.until { |lines| assert(lines.any? { |l| l.end_with?("/#{user}") }) }
@@ -190,14 +202,29 @@ module CompletionTest
# ~INVALID_USERNAME**<TAB> # ~INVALID_USERNAME**<TAB>
tmux.send_keys 'C-u' tmux.send_keys 'C-u'
tmux.send_keys 'cat ~such**', :Tab if shell == :fish
tmux.send_keys 'cat ~such', 'C-t'
else
tmux.send_keys "cat ~such#{trigger}", :Tab
end
tmux.until(true) { |lines| assert lines.any_include?('no~such~user') } tmux.until(true) { |lines| assert lines.any_include?('no~such~user') }
tmux.send_keys :Enter tmux.send_keys :Enter
tmux.until(true) { |lines| assert_equal 'cat no~such~user', lines[-1] } tmux.until(true) do |lines|
if shell == :fish
# Fish's string escape quotes filenames with ~ to prevent tilde expansion
assert_equal 'cat no\\~such\\~user', lines[-1]
else
assert_equal 'cat no~such~user', lines[-1]
end
end
# /tmp/fzf\ test**<TAB> # /tmp/fzf\ test**<TAB>
tmux.send_keys 'C-u' tmux.send_keys 'C-u'
tmux.send_keys 'cat /tmp/fzf\ test/**', :Tab if shell == :fish
tmux.send_keys 'cat /tmp/fzf\\ test/', 'C-t'
else
tmux.send_keys "cat /tmp/fzf\\ test/#{trigger}", :Tab
end
tmux.until { |lines| assert_operator lines.match_count, :>, 0 } tmux.until { |lines| assert_operator lines.match_count, :>, 0 }
tmux.send_keys 'foobar$' tmux.send_keys 'foobar$'
tmux.until do |lines| tmux.until do |lines|
@@ -210,7 +237,11 @@ module CompletionTest
# Should include hidden files # Should include hidden files
(1..100).each { |i| FileUtils.touch("/tmp/fzf-test/.hidden-#{i}") } (1..100).each { |i| FileUtils.touch("/tmp/fzf-test/.hidden-#{i}") }
tmux.send_keys 'C-u' tmux.send_keys 'C-u'
tmux.send_keys 'cat /tmp/fzf-test/hidden**', :Tab if shell == :fish
tmux.send_keys 'cat /tmp/fzf-test/hidden', 'C-t'
else
tmux.send_keys "cat /tmp/fzf-test/hidden#{trigger}", :Tab
end
tmux.until(true) do |lines| tmux.until(true) do |lines|
assert_equal 100, lines.match_count assert_equal 100, lines.match_count
assert lines.any_include?('/tmp/fzf-test/.hidden-') assert lines.any_include?('/tmp/fzf-test/.hidden-')
@@ -223,70 +254,89 @@ module CompletionTest
end end
def test_file_completion_root def test_file_completion_root
tmux.send_keys 'ls /**', :Tab if shell == :fish
tmux.send_keys 'ls /', 'C-t'
else
tmux.send_keys "ls /#{trigger}", :Tab
end
tmux.until { |lines| assert_operator lines.match_count, :>, 0 } tmux.until { |lines| assert_operator lines.match_count, :>, 0 }
tmux.send_keys :Enter tmux.send_keys :Enter
end end
def test_dir_completion def test_dir_completion
FileUtils.mkdir_p('/tmp/fzf-test-dir')
(1..100).each do |idx| (1..100).each do |idx|
FileUtils.mkdir_p("/tmp/fzf-test/d#{idx}") FileUtils.mkdir_p("/tmp/fzf-test-dir/d#{idx}")
end end
FileUtils.touch('/tmp/fzf-test/d55/xxx') FileUtils.touch('/tmp/fzf-test-dir/d55/xxx')
tmux.prepare tmux.prepare
tmux.send_keys 'cd /tmp/fzf-test/**', :Tab if shell == :fish
tmux.send_keys 'cd /tmp/fzf-test-dir/', 'C-t'
else
tmux.send_keys "cd /tmp/fzf-test-dir/#{trigger}", :Tab
end
tmux.until { |lines| assert_operator lines.match_count, :>, 0 } tmux.until { |lines| assert_operator lines.match_count, :>, 0 }
tmux.send_keys :Tab, :Tab # Tab does not work here # Tab selects items in C-t's --multi mode, so skip for fish
tmux.send_keys 55 tmux.send_keys :Tab, :Tab unless shell == :fish # Tab does not work here
tmux.send_keys '55/$'
tmux.until do |lines| tmux.until do |lines|
assert_equal 1, lines.match_count assert_equal 1, lines.match_count
assert_includes lines, '> 55' assert_includes lines, '> 55/$'
assert_includes lines, '> /tmp/fzf-test/d55/' assert_includes lines, '> /tmp/fzf-test-dir/d55/'
end end
tmux.send_keys :Enter tmux.send_keys :Enter
tmux.until(true) { |lines| assert_equal 'cd /tmp/fzf-test/d55/', lines[-1] } tmux.until(true) { |lines| assert_equal 'cd /tmp/fzf-test-dir/d55/', lines[-1] }
# C-t appends a trailing space after the result
tmux.send_keys :BSpace if shell == :fish
tmux.send_keys :xx tmux.send_keys :xx
tmux.until { |lines| assert_equal 'cd /tmp/fzf-test/d55/xx', lines[-1] } tmux.until { |lines| assert_equal 'cd /tmp/fzf-test-dir/d55/xx', lines[-1] }
# Should not match regular files (bash-only) # Should not match regular files (bash-only)
if instance_of?(TestBash) if instance_of?(TestBash)
tmux.send_keys :Tab tmux.send_keys :Tab
tmux.until { |lines| assert_equal 'cd /tmp/fzf-test/d55/xx', lines[-1] } tmux.until { |lines| assert_equal 'cd /tmp/fzf-test-dir/d55/xx', lines[-1] }
end end
# Fail back to plusdirs # Fail back to plusdirs
tmux.send_keys :BSpace, :BSpace, :BSpace tmux.send_keys :BSpace, :BSpace, :BSpace
tmux.until { |lines| assert_equal 'cd /tmp/fzf-test/d55', lines[-1] } tmux.until { |lines| assert_equal 'cd /tmp/fzf-test-dir/d55', lines[-1] }
tmux.send_keys :Tab tmux.send_keys :Tab
tmux.until { |lines| assert_equal 'cd /tmp/fzf-test/d55/', lines[-1] } tmux.until { |lines| assert_equal 'cd /tmp/fzf-test-dir/d55/', lines[-1] }
ensure
FileUtils.rm_rf('/tmp/fzf-test-dir')
end end
def test_process_completion def test_process_completion
tmux.send_keys 'sleep 12345 &', :Enter skip('fish background job format differs') if shell == :fish
lines = tmux.until { |lines| assert lines[-1]&.start_with?('[1] ') }
pid = lines[-1]&.split&.last begin
tmux.prepare tmux.send_keys 'sleep 12345 &', :Enter
tmux.send_keys 'C-L' lines = tmux.until { |lines| assert lines[-1]&.start_with?('[1] ') }
tmux.send_keys 'kill **', :Tab pid = lines[-1]&.split&.last
tmux.until { |lines| assert_operator lines.match_count, :>, 0 } tmux.prepare
tmux.send_keys 'sleep12345' tmux.send_keys 'C-L'
tmux.until { |lines| assert lines.any_include?('sleep 12345') } tmux.send_keys "kill #{trigger}", :Tab
tmux.send_keys :Enter tmux.until { |lines| assert_operator lines.match_count, :>, 0 }
tmux.until(true) { |lines| assert_equal "kill #{pid}", lines[-1] } tmux.send_keys 'sleep12345'
ensure tmux.until { |lines| assert lines.any_include?('sleep 12345') }
if pid tmux.send_keys :Enter
begin tmux.until(true) { |lines| assert_equal "kill #{pid}", lines[-1] }
Process.kill('KILL', pid.to_i) ensure
rescue StandardError if pid
nil begin
Process.kill('KILL', pid.to_i)
rescue StandardError
nil
end
end end
end end
end end
def test_custom_completion def test_custom_completion
skip('fish does not use _fzf_compgen_path; path completion is via ctrl-t') if shell == :fish
tmux.send_keys '_fzf_compgen_path() { echo "$1"; seq 10; }', :Enter tmux.send_keys '_fzf_compgen_path() { echo "$1"; seq 10; }', :Enter
tmux.prepare tmux.prepare
tmux.send_keys 'ls /tmp/**', :Tab tmux.send_keys "ls /tmp/#{trigger}", :Tab
tmux.until { |lines| assert_equal 11, lines.match_count } tmux.until { |lines| assert_equal 11, lines.match_count }
tmux.send_keys :Tab, :Tab, :Tab tmux.send_keys :Tab, :Tab, :Tab
tmux.until { |lines| assert_equal 3, lines.select_count } tmux.until { |lines| assert_equal 3, lines.select_count }
@@ -295,11 +345,12 @@ module CompletionTest
end end
def test_unset_completion def test_unset_completion
skip('fish has native completion for set and unset variables') if shell == :fish
tmux.send_keys 'export FZFFOOBAR=BAZ', :Enter tmux.send_keys 'export FZFFOOBAR=BAZ', :Enter
tmux.prepare tmux.prepare
# Using tmux # Using tmux
tmux.send_keys 'unset FZFFOOBR**', :Tab tmux.send_keys "unset FZFFOOBR#{trigger}", :Tab
tmux.until { |lines| assert_equal 1, lines.match_count } tmux.until { |lines| assert_equal 1, lines.match_count }
tmux.send_keys :Enter tmux.send_keys :Enter
tmux.until { |lines| assert_equal 'unset FZFFOOBAR', lines[-1] } tmux.until { |lines| assert_equal 'unset FZFFOOBAR', lines[-1] }
@@ -308,34 +359,57 @@ module CompletionTest
# FZF_TMUX=1 # FZF_TMUX=1
new_shell new_shell
tmux.focus tmux.focus
tmux.send_keys 'unset FZFFOOBR**', :Tab tmux.send_keys "unset FZFFOOBR#{trigger}", :Tab
tmux.until { |lines| assert_equal 1, lines.match_count } tmux.until { |lines| assert_equal 1, lines.match_count }
tmux.send_keys :Enter tmux.send_keys :Enter
tmux.until { |lines| assert_equal 'unset FZFFOOBAR', lines[-1] } tmux.until { |lines| assert_equal 'unset FZFFOOBAR', lines[-1] }
end end
def test_completion_in_command_sequence def test_completion_in_command_sequence
tmux.send_keys 'export FZFFOOBAR=BAZ', :Enter if shell == :fish
tmux.prepare FileUtils.mkdir_p('/tmp/fzf-test-seq')
FileUtils.touch('/tmp/fzf-test-seq/fzffoobar')
triggers = ['**', '~~', '++', 'ff', '/'] tmux.prepare
triggers.push('&', '[', ';', '`') if instance_of?(TestZsh) # Fish uses Shift-Tab for fzf completion (no trigger system)
command = 'echo foo; QUX=THUD ls /tmp/fzf-test-seq/fzffoobr'
triggers.each do |trigger| expected = 'echo foo; QUX=THUD ls /tmp/fzf-test-seq/fzffoobar'
set_var('FZF_COMPLETION_TRIGGER', trigger) tmux.send_keys command, :BTab
command = "echo foo; QUX=THUD unset FZFFOOBR#{trigger}"
tmux.send_keys command.sub(/(;|`)$/, '\\\\\1'), :Tab
tmux.until { |lines| assert_equal 1, lines.match_count } tmux.until { |lines| assert_equal 1, lines.match_count }
tmux.send_keys :Enter tmux.send_keys :Enter
tmux.until { |lines| assert_equal 'echo foo; QUX=THUD unset FZFFOOBAR', lines[-1] } tmux.until { |lines| assert_equal expected, lines[-1] }
else
tmux.send_keys 'export FZFFOOBAR=BAZ', :Enter
tmux.prepare
triggers = ['**', '~~', '++', 'ff', '/']
triggers.push('&', '[', ';', '`') if instance_of?(TestZsh)
triggers.each do |trigger|
set_var('FZF_COMPLETION_TRIGGER', trigger)
command = "echo foo; QUX=THUD unset FZFFOOBR#{trigger}"
expected = 'echo foo; QUX=THUD unset FZFFOOBAR'
tmux.send_keys command.sub(/(;|`)$/, '\\\\\1'), :Tab
tmux.until { |lines| assert_equal 1, lines.match_count }
tmux.send_keys :Enter
tmux.until { |lines| assert_equal expected, lines[-1] }
end
end end
ensure
FileUtils.rm_rf('/tmp/fzf-test-seq') if shell == :fish
end end
def test_file_completion_unicode def test_file_completion_unicode
FileUtils.mkdir_p('/tmp/fzf-test') FileUtils.mkdir_p('/tmp/fzf-test')
tmux.paste "cd /tmp/fzf-test; echo test3 > $'fzf-unicode \\355\\205\\214\\354\\212\\244\\355\\212\\2701'; echo test4 > $'fzf-unicode \\355\\205\\214\\354\\212\\244\\355\\212\\2702'" # Shell-agnostic file creation
File.write('/tmp/fzf-test/fzf-unicode 테스트1', "test3\n")
File.write('/tmp/fzf-test/fzf-unicode 테스트2', "test4\n")
tmux.send_keys 'cd /tmp/fzf-test', :Enter
tmux.prepare tmux.prepare
tmux.send_keys 'cat fzf-unicode**', :Tab if shell == :fish
tmux.send_keys 'cat fzf-unicode', 'C-t'
else
tmux.send_keys "cat fzf-unicode#{trigger}", :Tab
end
tmux.until { |lines| assert_equal 2, lines.match_count } tmux.until { |lines| assert_equal 2, lines.match_count }
tmux.send_keys '1' tmux.send_keys '1'
@@ -358,36 +432,41 @@ module CompletionTest
end end
def test_custom_completion_api def test_custom_completion_api
tmux.send_keys 'eval "_fzf$(declare -f _comprun)"', :Enter skip('bash-specific _comprun/declare syntax') if shell == :fish
%w[f g].each do |command|
tmux.prepare begin
tmux.send_keys "#{command} b**", :Tab tmux.send_keys 'eval "_fzf$(declare -f _comprun)"', :Enter
tmux.until do |lines| %w[f g].each do |command|
assert_equal 2, lines.item_count tmux.prepare
assert_equal 1, lines.match_count tmux.send_keys "#{command} b#{trigger}", :Tab
assert lines.any_include?("prompt-#{command}") tmux.until do |lines|
assert lines.any_include?("preview-#{command}-bar") assert_equal 2, lines.item_count
assert_equal 1, lines.match_count
assert lines.any_include?("prompt-#{command}")
assert lines.any_include?("preview-#{command}-bar")
end
tmux.send_keys :Enter
tmux.until { |lines| assert_equal "#{command} #{command}barbar", lines[-1] }
tmux.send_keys 'C-u'
end end
tmux.send_keys :Enter ensure
tmux.until { |lines| assert_equal "#{command} #{command}barbar", lines[-1] } tmux.prepare
tmux.send_keys 'C-u' tmux.send_keys 'unset -f _fzf_comprun', :Enter
end end
ensure
tmux.prepare
tmux.send_keys 'unset -f _fzf_comprun', :Enter
end end
def test_ssh_completion def test_ssh_completion
skip('fish uses native ssh completion') if shell == :fish
(1..5).each { |i| FileUtils.touch("/tmp/fzf-test-ssh-#{i}") } (1..5).each { |i| FileUtils.touch("/tmp/fzf-test-ssh-#{i}") }
tmux.send_keys 'ssh jg@localhost**', :Tab tmux.send_keys "ssh jg@localhost#{trigger}", :Tab
tmux.until do |lines| tmux.until do |lines|
assert_operator lines.match_count, :>=, 1 assert_operator lines.match_count, :>=, 1
end end
tmux.send_keys :Enter tmux.send_keys :Enter
tmux.until { |lines| assert lines.any_include?('ssh jg@localhost') } tmux.until { |lines| assert lines.any_include?('ssh jg@localhost') }
tmux.send_keys ' -i /tmp/fzf-test-ssh**', :Tab tmux.send_keys " -i /tmp/fzf-test-ssh#{trigger}", :Tab
tmux.until do |lines| tmux.until do |lines|
assert_operator lines.match_count, :>=, 5 assert_operator lines.match_count, :>=, 5
assert_equal 0, lines.select_count assert_equal 0, lines.select_count
@@ -399,11 +478,344 @@ module CompletionTest
tmux.send_keys :Enter tmux.send_keys :Enter
tmux.until { |lines| assert lines.any_include?('ssh jg@localhost -i /tmp/fzf-test-ssh-') } tmux.until { |lines| assert lines.any_include?('ssh jg@localhost -i /tmp/fzf-test-ssh-') }
tmux.send_keys 'localhost**', :Tab tmux.send_keys "localhost#{trigger}", :Tab
tmux.until do |lines| tmux.until do |lines|
assert_operator lines.match_count, :>=, 1 assert_operator lines.match_count, :>=, 1
end end
end end
def test_option_equals_long_option
FileUtils.mkdir_p('/tmp/fzf-test-opt-eq-long')
FileUtils.touch('/tmp/fzf-test-opt-eq-long/SECURITY.md')
tmux.prepare
tmux.send_keys 'cd /tmp/fzf-test-opt-eq-long', :Enter
tmux.prepare
if shell == :fish
tmux.send_keys 'some-command --opt=SECURI', 'C-t'
else
tmux.send_keys "some-command --opt=SECURI#{trigger}", :Tab
end
case shell
when :bash
tmux.until do |lines|
assert_equal 1, lines.match_count
assert_includes lines, '> SECURI'
end
tmux.send_keys :Enter
tmux.until(true) { |lines| assert_equal 'some-command --opt=SECURITY.md', lines[-1] }
when :fish
tmux.until do |lines|
assert_equal 1, lines.match_count
assert lines.any_include?('SECURITY.md')
end
tmux.send_keys :Enter
tmux.until(true) { |lines| assert_equal 'some-command --opt=SECURITY.md', lines[-1] }
when :zsh
tmux.until do |lines|
assert_equal 0, lines.match_count
assert_includes lines, '> --opt=SECURI'
end
end
ensure
FileUtils.rm_rf('/tmp/fzf-test-opt-eq-long')
end
def test_option_equals_long_option_after_double_dash
FileUtils.mkdir_p('/tmp/fzf-test-opt-eq-long-ddash')
FileUtils.touch('/tmp/fzf-test-opt-eq-long-ddash/SECURITY.md')
tmux.prepare
tmux.send_keys 'cd /tmp/fzf-test-opt-eq-long-ddash', :Enter
tmux.prepare
if shell == :fish
tmux.send_keys 'some-command -- --opt=SECURI', 'C-t'
else
tmux.send_keys "some-command -- --opt=SECURI#{trigger}", :Tab
end
case shell
when :bash
tmux.until do |lines|
assert_equal 1, lines.match_count
assert_includes lines, '> SECURI'
end
tmux.send_keys :Enter
tmux.until(true) { |lines| assert_equal 'some-command -- --opt=SECURITY.md', lines[-1] }
when :fish, :zsh
tmux.until do |lines|
assert_equal 0, lines.match_count
assert_includes lines, '> --opt=SECURI'
end
end
ensure
FileUtils.rm_rf('/tmp/fzf-test-opt-eq-long-ddash')
end
def test_option_equals_short_option
FileUtils.mkdir_p('/tmp/fzf-test-opt-eq-short')
FileUtils.touch('/tmp/fzf-test-opt-eq-short/SECURITY.md')
tmux.prepare
tmux.send_keys 'cd /tmp/fzf-test-opt-eq-short', :Enter
tmux.prepare
if shell == :fish
tmux.send_keys 'some-command -o=SECURI', 'C-t'
else
tmux.send_keys "some-command -o=SECURI#{trigger}", :Tab
end
case shell
when :bash, :fish
tmux.until do |lines|
assert_equal 1, lines.match_count
assert lines.any_include?('> SECURITY.md')
end
tmux.send_keys :Enter
tmux.until(true) { |lines| assert_equal 'some-command -o=SECURITY.md', lines[-1] }
when :zsh
tmux.until do |lines|
assert_equal 0, lines.match_count
assert_includes lines, '> -o=SECURI'
end
end
ensure
FileUtils.rm_rf('/tmp/fzf-test-opt-eq-short')
end
def test_option_equals_short_option_after_double_dash
FileUtils.mkdir_p('/tmp/fzf-test-opt-eq-short-ddash')
FileUtils.touch('/tmp/fzf-test-opt-eq-short-ddash/SECURITY.md')
tmux.prepare
tmux.send_keys 'cd /tmp/fzf-test-opt-eq-short-ddash', :Enter
tmux.prepare
if shell == :fish
tmux.send_keys 'some-command -- -o=SECURI', 'C-t'
else
tmux.send_keys "some-command -- -o=SECURI#{trigger}", :Tab
end
case shell
when :bash
tmux.until do |lines|
assert_equal 1, lines.match_count
assert_includes lines, '> SECURITY.md'
end
tmux.send_keys :Enter
tmux.until(true) { |lines| assert_equal 'some-command -- -o=SECURITY.md', lines[-1] }
when :fish, :zsh
tmux.until do |lines|
assert_equal 0, lines.match_count
assert_includes lines, '> -o=SECURI'
end
end
ensure
FileUtils.rm_rf('/tmp/fzf-test-opt-eq-short-ddash')
end
def test_option_no_equals_long_option
FileUtils.mkdir_p('/tmp/fzf-test-opt-no-eq-long')
FileUtils.touch('/tmp/fzf-test-opt-no-eq-long/SECURITY.md')
tmux.prepare
tmux.send_keys 'cd /tmp/fzf-test-opt-no-eq-long', :Enter
tmux.prepare
if shell == :fish
tmux.send_keys 'some-command --optSECURI', 'C-t'
else
tmux.send_keys "some-command --optSECURI#{trigger}", :Tab
end
tmux.until do |lines|
assert_equal 0, lines.match_count
assert_includes lines, '> --optSECURI'
end
ensure
FileUtils.rm_rf('/tmp/fzf-test-opt-no-eq-long')
end
def test_option_no_equals_long_option_after_double_dash
FileUtils.mkdir_p('/tmp/fzf-test-opt-no-eq-long-ddash')
FileUtils.touch('/tmp/fzf-test-opt-no-eq-long-ddash/SECURITY.md')
tmux.prepare
tmux.send_keys 'cd /tmp/fzf-test-opt-no-eq-long-ddash', :Enter
tmux.prepare
if shell == :fish
tmux.send_keys 'some-command -- --optSECURI', 'C-t'
else
tmux.send_keys "some-command -- --optSECURI#{trigger}", :Tab
end
tmux.until do |lines|
assert_equal 0, lines.match_count
assert_includes lines, '> --optSECURI'
end
ensure
FileUtils.rm_rf('/tmp/fzf-test-opt-no-eq-long-ddash')
end
def test_option_no_equals_short_option
FileUtils.mkdir_p('/tmp/fzf-test-opt-no-eq-short')
FileUtils.touch('/tmp/fzf-test-opt-no-eq-short/SECURITY.md')
tmux.prepare
tmux.send_keys 'cd /tmp/fzf-test-opt-no-eq-short', :Enter
tmux.prepare
if shell == :fish
tmux.send_keys 'some-command -oSECURI', 'C-t'
else
tmux.send_keys "some-command -oSECURI#{trigger}", :Tab
end
case shell
when :bash, :zsh
tmux.until do |lines|
assert_equal 0, lines.match_count
assert_includes lines, '> -oSECURI'
end
when :fish
tmux.until do |lines|
assert_equal 1, lines.match_count
assert lines.any_include?('> SECURITY.md')
end
tmux.send_keys :Enter
tmux.until(true) { |lines| assert_equal 'some-command -oSECURITY.md', lines[-1] }
end
ensure
FileUtils.rm_rf('/tmp/fzf-test-opt-no-eq-short')
end
def test_option_no_equals_short_option_after_double_dash
FileUtils.mkdir_p('/tmp/fzf-test-opt-no-eq-short-ddash')
FileUtils.touch('/tmp/fzf-test-opt-no-eq-short-ddash/SECURITY.md')
tmux.prepare
tmux.send_keys 'cd /tmp/fzf-test-opt-no-eq-short-ddash', :Enter
tmux.prepare
if shell == :fish
tmux.send_keys 'some-command -- -oSECURI', 'C-t'
else
tmux.send_keys "some-command -- -oSECURI#{trigger}", :Tab
end
tmux.until do |lines|
assert_equal 0, lines.match_count
assert_includes lines, '> -oSECURI'
end
ensure
FileUtils.rm_rf('/tmp/fzf-test-opt-no-eq-short-ddash')
end
def test_filename_with_newline
FileUtils.mkdir_p('/tmp/fzf-test-newline')
FileUtils.touch("/tmp/fzf-test-newline/xyz\nwith\nnewlines")
tmux.prepare
tmux.send_keys 'cd /tmp/fzf-test-newline', :Enter
tmux.prepare
if shell == :fish
tmux.send_keys 'cat xyz', 'C-t'
else
tmux.send_keys "cat xyz#{trigger}", :Tab
end
case shell
when :fish
tmux.until do |lines|
assert_equal 1, lines.match_count
assert_includes lines, '> xyz'
end
tmux.send_keys :Enter
# fish escapes newlines in filenames
tmux.until(true) { |lines| assert_equal 'cat xyz\\nwith\\nnewlines', lines[-1] }
when :bash, :zsh
tmux.until do |lines|
assert_equal 1, lines.match_count
assert_includes lines, '> xyz'
end
tmux.send_keys :Enter
# bash and zsh replace newlines with spaces in filenames
tmux.until(true) { |lines| assert_equal 'cat xyz with newlines', lines[-1] }
end
ensure
FileUtils.rm_rf('/tmp/fzf-test-newline')
end
def test_path_with_special_chars
FileUtils.mkdir_p('/tmp/fzf-test-[special]')
FileUtils.touch('/tmp/fzf-test-[special]/xyz123')
tmux.prepare
if shell == :fish
tmux.send_keys 'ls /tmp/fzf-test-\[special\]/xyz', 'C-t'
else
tmux.send_keys "ls /tmp/fzf-test-\\[special\\]/xyz#{trigger}", :Tab
end
tmux.until { |lines| assert_equal 1, lines.match_count }
tmux.send_keys :Enter
tmux.until(true) { |lines| assert_equal 'ls /tmp/fzf-test-\\[special\\]/xyz123', lines[-1] }
ensure
FileUtils.rm_rf('/tmp/fzf-test-[special]')
end
def test_query_with_dollar_anchor
FileUtils.mkdir_p('/tmp/fzf-test-dollar-anchor')
FileUtils.touch('/tmp/fzf-test-dollar-anchor/file.txt')
FileUtils.touch('/tmp/fzf-test-dollar-anchor/filetxt.md')
tmux.prepare
tmux.send_keys 'cd /tmp/fzf-test-dollar-anchor', :Enter
tmux.prepare
if shell == :fish
tmux.send_keys 'ls txt$', 'C-t'
else
tmux.send_keys "ls txt$#{trigger}", :Tab
end
tmux.until do |lines|
assert_equal 1, lines.match_count
assert_includes lines, '> txt$'
end
tmux.send_keys :Enter
tmux.until(true) { |lines| assert_equal 'ls file.txt', lines[-1] }
ensure
FileUtils.rm_rf('/tmp/fzf-test-dollar-anchor')
end
def test_single_flag_completion
FileUtils.mkdir_p('/tmp/fzf-test-single-flag')
FileUtils.touch('/tmp/fzf-test-single-flag/-testfile.txt')
tmux.prepare
tmux.send_keys 'cd /tmp/fzf-test-single-flag', :Enter
tmux.prepare
if shell == :fish
tmux.send_keys 'ls -', 'C-t'
else
tmux.send_keys "ls -#{trigger}", :Tab
end
tmux.until do |lines|
assert_equal 1, lines.match_count
assert_includes lines, '> -'
end
tmux.send_keys :Enter
tmux.until(true) { |lines| assert_equal 'ls -testfile.txt', lines[-1] }
ensure
FileUtils.rm_rf('/tmp/fzf-test-single-flag')
end
def test_double_flag_completion
FileUtils.mkdir_p('/tmp/fzf-test-double-flag')
FileUtils.touch('/tmp/fzf-test-double-flag/--testfile.txt')
tmux.prepare
tmux.send_keys 'cd /tmp/fzf-test-double-flag', :Enter
tmux.prepare
if shell == :fish
tmux.send_keys 'ls --', 'C-t'
else
tmux.send_keys "ls --#{trigger}", :Tab
end
tmux.until do |lines|
assert_equal 1, lines.match_count
assert_includes lines, '> --'
end
tmux.send_keys :Enter
tmux.until(true) { |lines| assert_equal 'ls --testfile.txt', lines[-1] }
ensure
FileUtils.rm_rf('/tmp/fzf-test-double-flag')
end
end end
class TestBash < TestBase class TestBash < TestBase
@@ -424,7 +836,7 @@ class TestBash < TestBase
tmux.paste 'touch /tmp/foo; _fzf_completion_loader=1' tmux.paste 'touch /tmp/foo; _fzf_completion_loader=1'
tmux.paste '_completion_loader() { complete -o default fake; }' tmux.paste '_completion_loader() { complete -o default fake; }'
tmux.paste 'complete -F _fzf_path_completion -o default -o bashdefault fake' tmux.paste 'complete -F _fzf_path_completion -o default -o bashdefault fake'
tmux.send_keys 'fake /tmp/foo**', :Tab tmux.send_keys "fake /tmp/foo#{trigger}", :Tab
tmux.until { |lines| assert_operator lines.match_count, :>, 0 } tmux.until { |lines| assert_operator lines.match_count, :>, 0 }
tmux.send_keys 'C-c' tmux.send_keys 'C-c'
@@ -433,7 +845,7 @@ class TestBash < TestBase
tmux.send_keys :Tab, 'C-u' tmux.send_keys :Tab, 'C-u'
tmux.prepare tmux.prepare
tmux.send_keys 'fake /tmp/foo**', :Tab tmux.send_keys "fake /tmp/foo#{trigger}", :Tab
tmux.until { |lines| assert_operator lines.match_count, :>, 0 } tmux.until { |lines| assert_operator lines.match_count, :>, 0 }
end end
end end
@@ -455,24 +867,142 @@ class TestZsh < TestBase
tmux.send_keys 'export FZFFOOBAR=BAZ', :Enter tmux.send_keys 'export FZFFOOBAR=BAZ', :Enter
['unset', '\unset', "'unset'"].each do |command| ['unset', '\unset', "'unset'"].each do |command|
tmux.prepare tmux.prepare
tmux.send_keys "#{command} FZFFOOBR**", :Tab tmux.send_keys "#{command} FZFFOOBR#{trigger}", :Tab
tmux.until { |lines| assert_equal 1, lines.match_count } tmux.until { |lines| assert_equal 1, lines.match_count }
tmux.send_keys :Enter tmux.send_keys :Enter
tmux.until { |lines| assert_equal "#{command} FZFFOOBAR", lines[-1] } tmux.until { |lines| assert_equal "#{command} FZFFOOBAR", lines[-1] }
tmux.send_keys 'C-c' tmux.send_keys 'C-c'
end end
end end
# Helper function to run test with Perl and again with Awk
def self.test_perl_and_awk(name, &block)
define_method(:"test_#{name}") do
instance_eval(&block)
end
define_method(:"test_#{name}_awk") do
tmux.send_keys "unset 'commands[perl]'", :Enter
tmux.prepare
# Verify perl is actually unset (0 = not found)
tmux.send_keys 'echo ${+commands[perl]}', :Enter
tmux.until { |lines| assert_equal '0', lines[-1] }
tmux.prepare
instance_eval(&block)
end
end
def prepare_ctrl_r_test
tmux.send_keys ':', :Enter
tmux.send_keys 'echo match-collision', :Enter
tmux.prepare
tmux.send_keys 'echo "line 1', :Enter, '2 line 2"', :Enter
tmux.prepare
tmux.send_keys 'echo "foo', :Enter, 'bar"', :Enter
tmux.prepare
tmux.send_keys 'echo "bar', :Enter, 'foo"', :Enter
tmux.prepare
tmux.send_keys 'echo "trailing_space "', :Enter
tmux.prepare
tmux.send_keys 'cat <<EOF | wc -c', :Enter, 'qux thud', :Enter, 'EOF', :Enter
tmux.prepare
tmux.send_keys 'C-l', 'C-r'
end
test_perl_and_awk 'ctrl_r_accept_or_print_query' do
set_var('FZF_CTRL_R_OPTS', '--bind enter:accept-or-print-query')
prepare_ctrl_r_test
tmux.until { |lines| assert_operator lines.match_count, :>, 0 }
tmux.send_keys '1 foobar'
tmux.until { |lines| assert_equal 0, lines.match_count }
tmux.send_keys :Enter
tmux.until { |lines| assert_equal '1 foobar', lines[-1] }
end
test_perl_and_awk 'ctrl_r_multiline_index_collision' do
# Leading number in multi-line history content is not confused with index
prepare_ctrl_r_test
tmux.send_keys "'line 1"
tmux.until { |lines| assert_equal 1, lines.match_count }
tmux.send_keys :Enter
tmux.until do |lines|
assert_equal ['echo "line 1', '2 line 2"'], lines[-2..]
end
end
test_perl_and_awk 'ctrl_r_multi_selection' do
prepare_ctrl_r_test
tmux.until { |lines| assert_operator lines.match_count, :>, 0 }
tmux.send_keys :BTab, :BTab, :BTab
tmux.until { |lines| assert_includes lines[-2], '(3)' }
tmux.send_keys :Enter
tmux.until do |lines|
assert_equal ['cat <<EOF | wc -c', 'qux thud', 'EOF', 'echo "trailing_space "', 'echo "bar', 'foo"'], lines[-6..]
end
end
test_perl_and_awk 'ctrl_r_no_multi_selection' do
set_var('FZF_CTRL_R_OPTS', '--no-multi')
prepare_ctrl_r_test
tmux.until { |lines| assert_operator lines.match_count, :>, 0 }
tmux.send_keys :BTab, :BTab, :BTab
tmux.until { |lines| refute_includes lines[-2], '(3)' }
tmux.send_keys :Enter
tmux.until do |lines|
assert_equal ['cat <<EOF | wc -c', 'qux thud', 'EOF'], lines[-3..]
end
end
# NOTE: 'Perl/$history' won't see foreign cmds immediately, unlike 'awk/fc'.
# Perl passes only because another cmd runs between mocking and triggering C-r
# https://github.com/junegunn/fzf/issues/4061
# https://zsh.org/mla/users/2024/msg00692.html
test_perl_and_awk 'ctrl_r_foreign_commands' do
histfile = "#{tempname}-foreign-hist"
tmux.send_keys "HISTFILE=#{histfile}", :Enter
tmux.prepare
# SHARE_HISTORY picks up foreign commands; marked with * in fc
tmux.send_keys 'setopt SHARE_HISTORY', :Enter
tmux.prepare
tmux.send_keys 'fzf_cmd_local', :Enter
tmux.prepare
# Mock foreign command (for testing only; don't edit your HISTFILE this way)
tmux.send_keys "echo ': 0:0;fzf_cmd_foreign' >> $HISTFILE", :Enter
tmux.prepare
# Verify fc shows foreign command with asterisk
tmux.send_keys 'fc -rl -1', :Enter
tmux.until { |lines| assert(lines.any? { |l| l.match?(/^\s*\d+\* fzf_cmd_foreign/) }) }
tmux.prepare
# Test ctrl-r correctly extracts the foreign command
tmux.send_keys 'C-r'
tmux.until { |lines| assert_operator lines.match_count, :>, 0 }
tmux.send_keys '^fzf_cmd_'
tmux.until { |lines| assert_equal 2, lines.match_count }
tmux.send_keys :BTab, :BTab
tmux.until { |lines| assert_includes lines[-2], '(2)' }
tmux.send_keys :Enter
tmux.until do |lines|
assert_equal %w[fzf_cmd_foreign fzf_cmd_local], lines[-2..]
end
ensure
FileUtils.rm_f(histfile)
end
end end
class TestFish < TestBase class TestFish < TestBase
include TestShell include TestShell
include CompletionTest
def shell def shell
:fish :fish
end end
def trigger
'++'
end
def new_shell def new_shell
tmux.send_keys 'env FZF_TMUX=1 FZF_DEFAULT_OPTS=--no-scrollbar fish', :Enter tmux.send_keys 'env FZF_TMUX=1 XDG_CONFIG_HOME=/tmp/fzf-fish fish', :Enter
tmux.send_keys 'function fish_prompt; end; clear', :Enter tmux.send_keys 'function fish_prompt; end; clear', :Enter
tmux.until { |lines| assert_empty lines } tmux.until { |lines| assert_empty lines }
end end
@@ -490,15 +1020,23 @@ class TestFish < TestBase
tmux.send_keys 'echo "bar', :Enter, 'foo"', :Enter tmux.send_keys 'echo "bar', :Enter, 'foo"', :Enter
tmux.prepare tmux.prepare
tmux.send_keys 'C-l', 'C-r' tmux.send_keys 'C-l', 'C-r'
offset = -6
block = <<~BLOCK block = <<~BLOCK
echo "foo echo "foo
bar" bar"
echo "bar echo "bar
foo" foo"
BLOCK BLOCK
if shell == :fish
offset = -4
block = <<~FISH
echo "foo␊bar"
echo "bar␊foo"
FISH
end
tmux.until do |lines| tmux.until do |lines|
block.lines.each_with_index do |line, idx| block.lines.each_with_index do |line, idx|
assert_includes lines[-6 + idx], line.chomp assert_includes lines[idx + offset], line.chomp
end end
end end
tmux.send_keys :BTab, :BTab tmux.send_keys :BTab, :BTab
+6
View File
@@ -33,6 +33,9 @@ for opt in "$@"; do
esac esac
done done
cd "$(dirname "${BASH_SOURCE[0]}")"
fzf_base=$(pwd)
ask() { ask() {
while true; do while true; do
read -p "$1 ([y]/n) " -r read -p "$1 ([y]/n) " -r
@@ -94,12 +97,15 @@ done
bind_file="${fish_dir}/functions/fish_user_key_bindings.fish" bind_file="${fish_dir}/functions/fish_user_key_bindings.fish"
if [ -f "$bind_file" ]; then if [ -f "$bind_file" ]; then
remove_line "$bind_file" "fzf_key_bindings" remove_line "$bind_file" "fzf_key_bindings"
remove_line "$bind_file" "fzf_completion_setup"
remove_line "$bind_file" "fzf --fish | source" remove_line "$bind_file" "fzf --fish | source"
fi fi
if [ -d "${fish_dir}/functions" ]; then if [ -d "${fish_dir}/functions" ]; then
remove "${fish_dir}/functions/fzf.fish" remove "${fish_dir}/functions/fzf.fish"
remove "${fish_dir}/functions/fzf_key_bindings.fish" remove "${fish_dir}/functions/fzf_key_bindings.fish"
remove_line "$bind_file" "source \"${fzf_base}/shell/completion.fish\""
remove_line "$bind_file" "source \"${fzf_base}/shell/key-bindings.fish\""
if [ -z "$(ls -A "${fish_dir}/functions")" ]; then if [ -z "$(ls -A "${fish_dir}/functions")" ]; then
rmdir "${fish_dir}/functions" rmdir "${fish_dir}/functions"