Compare commits

..

228 Commits

Author SHA1 Message Date
Junegunn Choi f5fbfd848e Let bw theme inherit overridden colors
- Mark derived color slots undefined so fg/bg/list-bg propagate
- Add missing Footer slot (was rendering black)
2026-06-07 18:12:19 +09:00
Junegunn Choi dea72834ed Keep base fg/bg when resolving colors in bw theme
Trailing prompt space and other base-colored segments no longer reset to terminal default
2026-06-07 18:12:19 +09:00
LangLangBart abee152255 test: ALT-C regression tests
discussed in https://github.com/junegunn/fzf/issues/4816
2026-06-05 16:24:27 +09:00
LangLangBart bf114bcc21 test: install nushell in Dockerfile
ref: https://docs.docker.com/reference/dockerfile/#here-documents
2026-06-05 16:24:27 +09:00
Junegunn Choi 838ac7554b make lint 2026-06-02 20:23:23 +09:00
Junegunn Choi ae78a5c56d Allow bare put action in transform output
transform/bg-transform now permit bare `put`, inserting the key that
triggered the action (`a:transform:echo put` puts `a`).
2026-06-02 20:21:04 +09:00
Yi-Yo Chiang 7d647c70c2 [shell][zsh] Don't resolve symlinks in ALT-c (#4816)
This way ALT-c behaves more aligned with `cd`.

Imagine a setup like:
```
/foo -> foo_real
/foo_real/bar
```

Right now if we first `cd foo` (a symlink to `foo_real`), and
then use ALT-c to goto `bar`, then we would end up executing
`cd /foo_real/bar` instead of `cd /foo/bar`. `$PWD = /foo_real/bar`.

For comparison, if we first `cd foo` and then `cd bar`, we end up with
`$PWD = /foo/bar`.

This commit changes the internal logic of `fzf-cd-widget` to first run
`cd <result of FZF_ALT_C_COMMAND>` in a subshell to simulate the
behavior of `cd`, and then insert the target PWD into the shell history.
This way we get behavior consistent with the builtin `cd` command, while
also recording reusable shell history.
2026-05-31 12:38:12 +09:00
Junegunn Choi 6bd17f8f9a Revert "[shell][zsh] Don't resolve symlinks in ALT-c (#4816) (#4817)"
This reverts commit 249a6df4a4.
2026-05-31 12:37:05 +09:00
Junegunn Choi 249a6df4a4 [shell][zsh] Don't resolve symlinks in ALT-c (#4816) (#4817)
This way ALT-c behaves more aligned with `cd`.

Imagine a setup like:
```
/foo -> foo_real
/foo_real/bar
```

Right now if we first `cd foo` (a symlink to `foo_real`), and
then use ALT-c to goto `bar`, then we would end up executing
`cd /foo_real/bar` instead of `cd /foo/bar`. `$PWD = /foo_real/bar`.

For comparison, if we first `cd foo` and then `cd bar`, we end up with
`$PWD = /foo/bar`.

This commit changes the internal logic of `fzf-cd-widget` to first run
`cd <result of FZF_ALT_C_COMMAND>` in a subshell to simulate the
behavior of `cd`, and then insert the target PWD into the shell history.
This way we get behavior consistent with the builtin `cd` command, while
also recording reusable shell history.

Co-authored-by: Yi-Yo Chiang <5255547+silverneko@users.noreply.github.com>

Close #4816
2026-05-31 12:34:58 +09:00
Junegunn Choi a50619388d [install] Fix empty-shell detection in install script
${#shells} is the string length, not the shell count.

Thanks to @matheus-pacifico for the report.

Close #4813
2026-05-28 23:02:34 +09:00
Copilot 5ef8dea36e Prevent nushell source contamination in install shell loop (#4812) 2026-05-28 10:26:43 +09:00
Junegunn Choi 845752f305 Update README 2026-05-25 22:28:11 +09:00
Junegunn Choi 9a61a1457d Bump action versions for Node.js 24 support 2026-05-25 21:11:25 +09:00
Junegunn Choi dfcacb443d Allow manual dispatch of Winget workflow 2026-05-25 21:09:25 +09:00
Junegunn Choi 5412f39b84 Use PAT in release workflow
Releases created with the default GITHUB_TOKEN do not trigger other
workflows (anti-recursion). Winget workflow therefore did not fire
on v0.73.1. Switch to RELEASE_PAT (registered in the `release`
environment) so the release is authored by the user.
2026-05-25 21:08:48 +09:00
Junegunn Choi 07c5cd4185 Fix typo in CHANGELOG 2026-05-25 14:39:05 +09:00
Junegunn Choi ce4bef7595 0.73.1 2026-05-25 14:35:03 +09:00
Junegunn Choi 25868a62f7 Address code scanning alerts 2026-05-25 14:31:51 +09:00
Junegunn Choi 7963a2c658 server: avoid O(n^2) body accumulation in HTTP listener
- handleHttpRequest used `body += text` per token, allocating a new
  backing array on every append (O(n^2) total copy work)
- a single ~390 KB POST monopolised the single-threaded server for
  ~8 s, blocking all other --listen clients
- switch to strings.Builder for amortised O(n)

Reported with fix by Michal Majchrowicz and Marcin Wyczechowski
(AFINE Team).
2026-05-25 14:10:52 +09:00
Junegunn Choi 4b23aa45a8 Skip FZF_CURRENT_ITEM export when item contains NUL
- exec(2) rejects env entries containing NUL, breaking preview and
  other child commands when the input has NUL bytes
- skip the export and document the limitation

Fix #2395
2026-05-25 14:06:50 +09:00
Junegunn Choi 3953d1c649 Add make tag for release tagging
- `prerelease`: version-consistency grep across CHANGELOG, install,
  install.ps1, and both man pages (extracted from `release` target)
- `tag`: depends on `prerelease`; signs and pushes the version tag
- RELEASE.md: replace manual tag/push steps with `make tag`
2026-05-24 01:19:01 +09:00
Junegunn Choi 5e137613d3 Add GitHub Actions release workflow
- Triggers on tag push (v*); fires real release
- workflow_dispatch for dry runs (--snapshot --skip=publish)
- Gated by `release` environment with required reviewer
- RELEASE.md documents tag-only push flow and dispatch testing
2026-05-24 00:59:41 +09:00
Junegunn Choi a24deef77b 0.73.0 2026-05-24 00:15:16 +09:00
Junegunn Choi 1b6e17ca39 nushell: [uninstall] Query nu for autoload directory
Mirror the install fix. Fall back to the XDG path when `nu` is no
longer on PATH (e.g. nushell already uninstalled).
2026-05-24 00:13:24 +09:00
Junegunn Choi 94f6daa61c nushell: [install] Query nu for autoload directory
Nushell uses platform-native config dirs (Application Support on
macOS, AppData on Windows), so $XDG_CONFIG_HOME/nushell/autoload is
wrong outside Linux and the generated file is silently ignored.
Ask `nu` for `$nu.user-autoload-dirs` instead. Safe because the
earlier shells loop already drops `nushell` from $shells when `nu`
is not on PATH.
2026-05-23 23:36:24 +09:00
Junegunn Choi 02594f8dbc nushell: Rename duplicate __fzf_defaults in completion.nu
`fzf --nushell` concatenates key-bindings.nu and completion.nu, both
of which defined `__fzf_defaults`, causing the resulting autoload file
to fail parsing:

  Error: nu::parser::duplicate_command_def

    x Duplicate command definition within a block.
       ,-[/Users/jg/.config/nushell/autoload/_fzf_integration.nu:211:5]
   210 | # Helper to build default fzf options list
   211 | def __fzf_defaults [prepend: string, append: string]: nothing -> string {
       :     ------+------
       :           `-- defined more than once
   212 |   let base = $"--height ($env.FZF_TMUX_HEIGHT? | default '40%') ...
       `----

Rename the completion.nu copy to `__fzf_defaults_completion`.
2026-05-23 23:34:04 +09:00
Junegunn Choi f81cb1939c nushell: [install] Print absolute path to the generated file 2026-05-23 23:32:00 +09:00
Simon Désaulniers 290b18d9fe shell: nushell integration scripts (#4630)
Co-authored-by: imsys <911254+imsys@users.noreply.github.com>
Co-authored-by: Grzegorz Zalewski (Greg) <12560152+zalewskigrzegorz@users.noreply.github.com>
Co-authored-by: René Jochum <rene@jochum.dev>
Co-authored-by: Junegunn Choi <junegunn.c@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-23 23:13:46 +09:00
Junegunn Choi ccedd064ca Fix integer overflow in FuzzyMatchV2 guard on 32-bit builds
On 32-bit platforms (GOARCH=386, arm), N*M overflows int when N is
large and M approaches 1000, wrapping negative. The wrapped value
slips past both `N*M > cap(slab.I16)` and `M > 1000`, so the V1
fallback is skipped and alloc16 panics on a negative slice bound.

Cast to int64 before multiplying.

Affects shipped 32-bit ARM builds (linux_armv5/6/7, windows_armv5/6/7).

Reported with fix by Michal Majchrowicz and Marcin Wyczechowski
(AFINE Team).
2026-05-23 23:09:58 +09:00
dependabot[bot] d4352a013d Bump github.com/mattn/go-isatty from 0.0.20 to 0.0.22 (#4785)
Bumps [github.com/mattn/go-isatty](https://github.com/mattn/go-isatty) from 0.0.20 to 0.0.22.
- [Commits](https://github.com/mattn/go-isatty/compare/v0.0.20...v0.0.22)

---
updated-dependencies:
- dependency-name: github.com/mattn/go-isatty
  dependency-version: 0.0.22
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-23 23:09:26 +09:00
dependabot[bot] 665bef56ea Bump ruby/setup-ruby from 1.299.0 to 1.308.0 (#4799)
Bumps [ruby/setup-ruby](https://github.com/ruby/setup-ruby) from 1.299.0 to 1.308.0.
- [Release notes](https://github.com/ruby/setup-ruby/releases)
- [Changelog](https://github.com/ruby/setup-ruby/blob/master/release.rb)
- [Commits](https://github.com/ruby/setup-ruby/compare/3ff19f5e2baf30647122352b96108b1fbe250c64...97ecb7b512899eb71ab1bf2310a624c6f1589ac6)

---
updated-dependencies:
- dependency-name: ruby/setup-ruby
  dependency-version: 1.308.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Junegunn Choi <junegunn.c@gmail.com>
2026-05-23 23:08:58 +09:00
dependabot[bot] e912cdb3e4 Bump actions/dependency-review-action from 4 to 5 (#4794)
Bumps [actions/dependency-review-action](https://github.com/actions/dependency-review-action) from 4 to 5.
- [Release notes](https://github.com/actions/dependency-review-action/releases)
- [Commits](https://github.com/actions/dependency-review-action/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/dependency-review-action
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Junegunn Choi <junegunn.c@gmail.com>
2026-05-23 23:08:43 +09:00
Junegunn Choi de1fca99d5 Export FZF_CURRENT_ITEM to child processes (#4803)
Close #4802
2026-05-23 22:28:03 +09:00
Junegunn Choi 677e854850 Add --preview-window=next position (#4801)
Places preview adjacent to input on the list side: above input in the
default layout, below it in --layout=reverse.

  fzf --preview 'cat {}' --preview-window=next

Close #4798
2026-05-23 10:32:19 +09:00
Junegunn Choi 67319aed0b Fix rendering glitch with preview window on the left + footer 2026-05-19 18:54:35 +09:00
Junegunn Choi 367177d911 Fix RuboCop errors 2026-05-19 18:54:15 +09:00
Junegunn Choi 5819e5ff2f Fix bg-transform reload/exclude
Async callbacks fire a later iteration than the one that scheduled
them, so newCommand/reloadSync/denylist must persist across iterations.

  fzf --bind 'space:bg-transform:echo reload:date'
2026-05-18 18:48:00 +09:00
Junegunn Choi fcc3c6acce Add every(N) bind event and FZF_IDLE_TIME env var (#4797)
- every(N) fires every N seconds (fractional, floored to 0.01s)
- Encoded as tui.Every with duration in Char as milliseconds, so
  every(1) and every(2) coexist as distinct keymap entries
- FZF_IDLE_TIME exposes whole seconds since the last user activity
  (keystroke or mouse event); pair with every() for idle-based
  patterns like auto-accept/auto-quit

Close #1211
2026-05-17 23:54:59 +09:00
Junegunn Choi e0d081906f Reward non-word match at word boundary
A non-word character (e.g. '.') used to receive a flat bonusNonWord
regardless of context. Now it gets bonusBoundaryWhite at the start of
input and bonusBoundaryDelimiter right after a delimiter, matching the
treatment of word characters at the same boundaries.

Without this, '.completion' matching '.completion' lost to
'bash_completion.d/completions/X' because the consecutive chunk anchor
in the long path (the 'c' after '/') received bonusBoundaryDelimiter
while the exact match's '.' was capped at bonusNonWord.

Fix #4795
2026-05-15 00:46:36 +09:00
Junegunn Choi 263eb4732f Strip UTF-8-encoded C1 control characters from rendered items
The display sanitizer already stripped raw 8-bit C1 bytes (0x80-0x9F)
because they decode to RuneError as standalone bytes. Their valid UTF-8
encodings (0xC2 0x80 .. 0xC2 0x9F) decode to the same code points but
were passed through, allowing a filename or input line containing CSI
(U+009B), OSC (U+009D), or DCS (U+0090) to inject terminal control
sequences when rendered.
2026-05-05 09:56:59 +09:00
Junegunn Choi b4a86a9c8a Preserve wrap state across change-preview-window
toggle-preview-wrap (and -wrap-word) modifies t.activePreviewOpts.wrap,
but change-preview-window resets t.previewOpts to t.initialPreviewOpts,
discarding the user's toggle. Carry wrap and wrapWord over so toggles
survive a layout change. Explicit wrap / nowrap tokens in the new spec
still win, so cycling and the empty-token reset are unaffected.

Close #4791
2026-05-02 15:40:41 +09:00
Junegunn Choi 6fefe02546 0.72.0 2026-04-26 18:04:37 +09:00
Junegunn Choi f783582561 Rename internal variables to match user-facing names 2026-04-26 14:47:57 +09:00
Junegunn Choi af42fde089 Fix rubocop errors 2026-04-26 14:47:06 +09:00
Junegunn Choi 27dab2422e Change the example command in the 0.72.0 changelog 2026-04-21 18:48:00 +09:00
Junegunn Choi 56be41218c Redraw when change-footer changes line count
Mirror of the earlier change-header fix. The inline footer slot's row
budget depends on footer content length, but resizeIfNeeded() tolerates
a shorter-than-wanted inline window, so extra lines get clipped. Drive
a redraw on length change to re-run the layout.
2026-04-21 18:41:48 +09:00
Junegunn Choi 7782da6c00 Add dashed border style
New --border=dashed / --list-border=dashed / --header-border=dashed etc.
Uses U+2576 (╶) for horizontal edges and U+2506 (┆) for verticals, with
rounded corners (╭╮╰╯) and sharp T-junction mids (├┤). Terminal cells
are taller than wide (~2:1), so horizontals use a sparse stub per cell
while verticals need more dashes per cell to look evenly dashed.
Works with inline sections.
2026-04-20 22:23:41 +09:00
Junegunn Choi 43f0508dd2 Update CHANGELOG (0.72.0) 2026-04-20 00:55:43 +09:00
Junegunn Choi 1986d101e0 Redraw when change-header changes line count
The inline header slot's row budget depends on header content length,
but resizeIfNeeded() tolerates a shorter-than-wanted inline window, so
the stale slot stays. Drive a redraw on length change to re-run the
layout.
2026-04-20 00:47:16 +09:00
Junegunn Choi dd7a081b93 Let inline sections take precedence over --header-first
--header-first previously was rejected with --header-border=inline or
--header-lines-border=inline. Now, inline placement wins: an inline
section stays inside the list frame, and --header-first only affects
non-inline sections (mainly the main --header).
2026-04-20 00:47:16 +09:00
Junegunn Choi d247284bc3 Set inline separator caps per side 2026-04-20 00:47:16 +09:00
Junegunn Choi 6f3e5de150 Update man page: clarify inline list-border shape requirements 2026-04-20 00:47:16 +09:00
Junegunn Choi 0142fe9d04 Update CHANGELOG: clarify inline list-border shape requirements 2026-04-20 00:47:16 +09:00
Junegunn Choi 251b08b831 Fix misleading comment on printHeader nil-window guard
The guard fires when hasHeaderWindow() returned false at resize time,
not when addInline had no budget (placeInlineStack always leaves a
non-nil 0-height placeholder).
2026-04-20 00:47:16 +09:00
Junegunn Choi 01567b4f06 Fix inline header/footer border color when falling back to line
InitTheme was called before the runtime coerced BorderInline to
BorderLine, so HeaderBorder / FooterBorder inherited from ListBorder
even when the effective shape was 'line'. Mirror the coercion so
color inheritance matches the rendered shape.
2026-04-20 00:47:16 +09:00
Junegunn Choi d754cfab87 Add --{header,header-lines,footer}-border=inline
New BorderShape that embeds the section inside the --list-border
frame, joined to the list content by a horizontal separator with
T-junctions where the list shape has side borders. Requires a list
border with both top and bottom segments; falls back to 'line'
otherwise. Stacks when multiple sections are inline.

Sections inherit --color list-border by default and are colored as a
uniform block via their own --color *-border and *-bg.

Incompatible with --header-first. --header-border=inline requires
--header-lines-border to be inline or unset.
2026-04-20 00:47:16 +09:00
Junegunn Choi ef6eba1b89 Add a test case for click-header with --header and --header-lines combined
The two sections swap order between layouts, and header-lines are
reversed under layout=default, so expected LINE values differ per
layout.
2026-04-20 00:47:16 +09:00
Junegunn Choi faa6a7e9f6 Add test cases for click-header and click-footer
Injects SGR 1006 mouse events via tmux send-keys -l to exercise
FZF_CLICK_HEADER_* / FZF_CLICK_FOOTER_* across all three layouts,
with and without a header border.
2026-04-20 00:47:16 +09:00
Junegunn Choi 8b66489987 Clean up non-ascii characters 2026-04-20 00:47:16 +09:00
Vulcalien 780a624ed7 [vim] Move and resize popup window when detecting VimResized event
Close #4778
2026-04-20 00:25:02 +09:00
Tymoteusz Makowski 1ad7c617b1 fix: add no-modifiers fallbacks
Close #4776
2026-04-19 14:38:36 +09:00
Daniel Levin 06e70570e2 Update Makefile to support builds on Illumos
Close #4772
2026-04-13 20:07:55 +09:00
Junegunn Choi bd4a18d0a9 Replace sort.Sort(ByOrder(...)) with slices.SortFunc 2026-04-12 10:43:47 +09:00
Junegunn Choi 1b0d448c6e Update README: --tmux -> --popup 2026-04-12 09:57:26 +09:00
Junegunn Choi f584cf38f7 [bash] Persist history deletion when histappend is on
Fix #4764

Patch suggested by @bitraid.
2026-04-11 17:03:25 +09:00
Junegunn Choi 0eb2ae9f8b Fix gutter display in --style=minimal 2026-04-08 23:28:10 +09:00
Junegunn Choi 1831500018 Update README
I was unable to make the sponsors action to work with the new branch
protection rule. So I'm removing the sponsors section for now until
I can properly set it up again.
2026-04-05 22:20:25 +09:00
Junegunn Choi 62899fd74e 0.71.0 2026-04-04 14:04:34 +09:00
Junegunn Choi d1cea64a0e Allow adaptive height with negative value
Fix #4682
2026-04-04 10:13:19 +09:00
bitraid d7a132b8bf Update changelog 2026-04-04 09:48:00 +09:00
bitraid 59b82670f7 fish: Key bindings now requires fish v3.4.0+ 2026-04-04 09:47:59 +09:00
bitraid c516acaa89 Add .fish to .editorconfig shell script rules 2026-04-04 09:47:59 +09:00
bitraid 11ff4745ad Fix typo in install script 2026-04-04 09:47:59 +09:00
bitraid 23164c2263 fish: Completion script rewrite (SHIFT-TAB) 2026-04-04 09:47:59 +09:00
bitraid 9f8294de62 fish: Fixes and improvements to CTRL-R (#4703)
Fixes:
- Commands with trailing newlines
- Very long previews

Improvements:
- SHIFT-DELETE performance
- Bind ALT-T: cycle command prefix (timestamp, date/time, none)
- Set comment color for prefix
2026-04-04 09:47:59 +09:00
Junegunn Choi b7138e67a6 Update README 2026-04-03 18:58:57 +09:00
Junegunn Choi 0f0751b3b6 Add CODEOWNERS 2026-04-03 00:01:37 +09:00
dependabot[bot] cc16a97a40 Bump ruby/setup-ruby from 1.296.0 to 1.299.0 (#4753)
Bumps [ruby/setup-ruby](https://github.com/ruby/setup-ruby) from 1.296.0 to 1.299.0.
- [Release notes](https://github.com/ruby/setup-ruby/releases)
- [Changelog](https://github.com/ruby/setup-ruby/blob/master/release.rb)
- [Commits](https://github.com/ruby/setup-ruby/compare/eab2afb99481ca09a4e91171a8e0aee0e89bfedd...3ff19f5e2baf30647122352b96108b1fbe250c64)

---
updated-dependencies:
- dependency-name: ruby/setup-ruby
  dependency-version: 1.299.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-31 00:10:35 +09:00
dagecko 3ccf162cf0 fix: pin 6 unpinned action(s) (#4750) 2026-03-30 13:20:11 +09:00
PJ Eby fff7eda9d7 Terminate child processes on Windows (#4723)
Windows doesn't have signals, the default Kill doesn't actually kill
anything, and other forms of termination don't allow cleanup. So we
spawn preview processes in a new process group and send them a
CTRL_BREAK_EVENT to terminate.

However, we only do this for "pwsh" (PowerShell 7+) and unknown/
posix-ish shells, because cmd.exe and Windows PowerShell
("powershell.exe") don't always exit on Ctrl-Break. pwsh also needs
the -NonInteractive flag to exit on Ctrl-Break.

If the process wasn't given its own group, or if sending the console
control event fails, we fall back to the standard Kill (which likely
won't help, but doesn't hurt to try).

Fix #3134
2026-03-29 12:40:24 +09:00
junegunn d198c0c9de Deploying to master from @ junegunn/fzf@55d5b153e6 🚀 2026-03-28 15:05:52 +00:00
harrywzl 55d5b153e6 docs: fix duplicate table rows and minor grammar/formatting in fzf.txt (#4743) 2026-03-27 15:01:12 +09:00
Junegunn Choi 57695b0309 Remove deprecated FZF_TMUX vars from shell script headers 2026-03-26 21:35:14 +09:00
Junegunn Choi 6f17d49dbb Support zellij floating pane via --popup (new name for --tmux) (#4145) 2026-03-26 21:32:56 +09:00
Junegunn Choi 87586ac5eb Only use --no-same-owner with tar when supported
Fix #4740
Fix #4741
2026-03-26 21:06:58 +09:00
Junegunn Choi 12be5e7b83 Skip adaptive height validation when --tmux overrides --height
Fix #4742
2026-03-26 20:56:36 +09:00
Qingwei Li 1acf980e95 enhancement: move server declaration to go func to allocate it in stack (#4739)
Fix #4735
Close #4736
2026-03-25 15:22:49 +09:00
cui 14e3995a06 Fix nthTransformer parts slice preallocation (#4734)
Use make([]NthParts, 0, len(indexes)) so the slice starts empty with
reserved capacity. The previous length-len(indexes) allocation left
leading zero NthParts entries before appended elements.
2026-03-23 09:55:00 +09:00
Junegunn Choi 2202481705 Add devel branch to Linux CI workflow trigger 2026-03-22 10:18:17 +09:00
Junegunn Choi 6153004070 Always check hasPreviewWindow() before processing preview mouse events 2026-03-22 10:09:57 +09:00
Junegunn Choi 95f186f364 Fix preview scrollbar not rendered after toggle-preview
Fix #4732
2026-03-22 09:55:07 +09:00
Junegunn Choi 58b2855513 bash: Fix CTRL-R batch deletion
'echo {+1}' would paste multiple offsets in a line.

Fix #4730
2026-03-22 09:20:51 +09:00
Junegunn Choi a00df93e13 bash: CTRL-R now supports multi-select and batch deletion
- Changed +m to --multi to enable multi-select in CTRL-R
- Changed exclude to exclude-multi and {1} to {+1} so
  shift-delete removes all selected entries at once
2026-03-21 21:41:22 +09:00
Junegunn Choi 76efddd718 bash: shift-delete to delete history entries during CTRL-R
Close #4715
2026-03-21 21:10:43 +09:00
Junegunn Choi b638ff46fb Include match positions in GET / HTTP response
Close #4726
2026-03-20 23:06:16 +09:00
Junegunn Choi 259e841a77 Add pull request template with contribution policy 2026-03-20 22:50:12 +09:00
Junegunn Choi f0a2f5ef14 Skip symlinks targeting ancestors of walker root to prevent resource exhaustion
When --walker=follow is used, symlinks like Wine's z: -> / cause fzf to
traverse the entire root filesystem. fastwalk's built-in loop detection
only catches this on the second pass, but a single pass through / already
causes severe CPU and memory exhaustion.

This fix resolves each symlink-to-directory target to its absolute real
path and skips it if it is an ancestor of (or equal to) the walker root.

Close #4710
2026-03-18 20:43:00 +09:00
Junegunn Choi 2ae7367e8a Revert "Use IgnoreDuplicateDirs to prevent duplicate directory traversal"
This reverts commit 6f33df755e.
2026-03-18 18:57:40 +09:00
Junegunn Choi 6f33df755e Use IgnoreDuplicateDirs to prevent duplicate directory traversal
When --walker=follow is used, symlink following is now handled by
fastwalk's IgnoreDuplicateDirs adapter which tracks visited directories
by device+inode. This prevents the same directory from being entered
more than once, avoiding effectively infinite traversal when a symlink
points outside the walker root.

Close #4710
2026-03-18 10:17:49 +09:00
Eman Resu 2aec7d5201 Fix typo in 0.71 changelog entry (#4721)
Thanks!
2026-03-17 11:45:00 +09:00
David Leadbeater fc60406684 Add "unix" and "fattr" promises (#4719)
Without this fzf --listen=/tmp/foo.sock fails on OpenBSD.
2026-03-17 11:44:13 +09:00
Junegunn Choi cf57950301 Add --id-nth to bash option completion 2026-03-15 11:31:36 +09:00
Junegunn Choi 48c4913392 'reload' should not preserve multi-selection 2026-03-15 11:30:55 +09:00
Junegunn Choi 17f2aa1a1f Reorder info line: N/M (X) (S%) +S +T*
Flags that appear/disappear are now at the end, so the multi-select
indicator stays in a fixed position and doesn't flicker.
2026-03-14 22:11:46 +09:00
Junegunn Choi b5f7221580 Replace --track=NTH with --id-nth for cross-reload item identity
Separate item identity from cursor tracking:
- Add --id-nth=NTH to define item identity fields for cross-reload ops
- --track reverts to a simple boolean flag
- track-current action no longer accepts nth argument
- With --multi, selections are preserved across reload-sync by matching
  identity keys in the reloaded list

Close #4718
Close #4701
Close #4483
Close #4409
Close #3460
Close #2441
2026-03-14 21:49:16 +09:00
Junegunn Choi e6b9a08699 Defer list rendering until pending event actions are processed 2026-03-14 20:52:04 +09:00
Junegunn Choi 8dbb3b352d Do not restart matcher during reload-sync with the intermediate list 2026-03-14 20:52:04 +09:00
Junegunn Choi 9f422851fe Add field-based tracking across reloads (--track=NTH)
Allow --track to accept an optional nth expression for cross-reload
tracking. When a reload is triggered, fzf extracts a tracking key from
the current item using the nth expression, blocks the UI, and searches
for a matching item in the reloaded list.

- --track=.. tracks by entire line, --track=1 by first field, etc.
- --track without NTH retains existing index-based behavior
- UI is blocked during search (dimmed query, hidden cursor, +T*/+t*)
- reload unblocks eagerly on match; reload-sync waits for stream end
- Escape/Ctrl-C cancels blocked state without quitting
- track-current action accepts optional nth: track-current(1)
- Validate nth expression at parse time for both --track and track()
- Cache trackKeyFor results per item to avoid redundant computation
- Rename executeRegexp to argActionRegexp

Close #4701
Close #3460
2026-03-14 20:52:04 +09:00
Junegunn Choi 7a811f0cb8 Fix --no-sort jumpiness by merging results in index order
Fix #4717
2026-03-14 20:43:30 +09:00
Evan Hahn b80059e21f Fix zsh history widget when sh_glob option is on (#4714)
Fixes #4712.

The CTRL-R history widget failed in zsh when [zsh's `sh_glob` option][0]
was on. This fixes that by disabling the option locally, like we disable
other options.

[0]: https://zsh.sourceforge.io/Doc/Release/Options.html#:~:text=SH%5FGLOB,ksh%2E
2026-03-13 09:21:04 +09:00
Junegunn Choi 26de195bbb Update typos.toml 2026-03-13 09:08:01 +09:00
Junegunn Choi b59f27ef5a Fix --with-shell not handling quoted arguments correctly
Fix #4709

Use go-shellwords instead of strings.Fields to parse --with-shell,
so paths with spaces can be properly quoted.

  ln -s /bin/bash "/tmp/ba sh"

  fzf --with-shell='/tmp/ba\ sh -c' --preview 'echo hello world'

  fzf --with-shell='"/tmp/ba sh" -c' --preview 'echo hello world'
2026-03-09 22:09:36 +09:00
Junegunn Choi f3ca0b1365 Fix OSC8 hyperlinks mangled when URL contains unicode
Fix #4707
2026-03-08 13:55:14 +09:00
Junegunn Choi a8e1ef0989 Add CHANGELOG.md entry for 0.70.1 2026-03-08 11:54:56 +09:00
Junegunn Choi 2f27a3ede2 Replace []Result cache with bitmap cache for reduced memory usage
Replace the per-chunk query cache from []Result slices to fixed-size
bitmaps (ChunkBitmap: [16]uint64 = 128 bytes per entry). Each bit
indicates whether the corresponding item in the chunk matched.

This reduces cache memory by 86 times in testing:
- Old []Result cache: ~22KB per chunk per query (for 500 matches)
- New bitmap cache:   ~262 bytes per chunk per query (fixed)

With the reduced per-entry cost, queryCacheMax is raised from
chunkSize/5 to chunkSize/2, allowing broader queries (up to 50% match
rate) to be cached while still using far less memory.
2026-03-08 11:49:28 +09:00
junegunn 9249ea1739 Deploying to master from @ junegunn/fzf@92bfe68c74 🚀 2026-03-07 15:02:15 +00:00
Junegunn Choi 92bfe68c74 Use a shared work queue instead of static partitioning in matcher
Replace static chunk partitioning (sliceChunks) with a shared atomic
counter that workers pull from. This gives natural load balancing;
workers that finish chunks quickly grab more work instead of idling.

With this change, NumCPU workers suffice (no need for 8x oversubscription),
reducing goroutine overhead while improving throughput by 5-22%.

Now the performance scales linearly to the number of threads:

=== query: 'linux' ===
  [all]   baseline:    17.12ms  current:    14.28ms  (1.20x)  matches: 179966 (12.79%)
  [1T]    baseline:   136.49ms  current:   137.25ms  (0.99x)  matches: 179966 (12.79%)
  [2T]    baseline:    75.74ms  current:    68.75ms  (1.10x)  matches: 179966 (12.79%)
  [4T]    baseline:    41.16ms  current:    34.97ms  (1.18x)  matches: 179966 (12.79%)
  [8T]    baseline:    32.82ms  current:    17.79ms  (1.84x)  matches: 179966 (12.79%)
2026-03-07 18:26:42 +09:00
Junegunn Choi 92dc40ea82 Print ingestion time in --bench output 2026-03-07 18:13:38 +09:00
Junegunn Choi 12a280ba14 Fix lint errors 2026-03-07 18:13:38 +09:00
Junegunn Choi 0c6ead6e98 Replace procFun map with fixed-size array for faster algo dispatch
termType is already a small integer enum (0-5), so a [6]algo.Algo
array avoids hash table overhead in the extendedMatch hot loop.
2026-03-07 14:19:05 +09:00
Junegunn Choi 280a011f02 With a non-default --delimiter, --{accept,with}-nth should not remove trailing whitespaces 2026-03-07 13:39:55 +09:00
Junegunn Choi d324580840 Fix AWK tokenizer not treating a new line character as whitespace 2026-03-07 11:45:02 +09:00
Junegunn Choi f9830c5a3d Fix test cases not to fail on small screens (contd.) 2026-03-06 19:43:16 +09:00
Junegunn Choi 95bc5b8f0c Fix test cases not to fail on small screens 2026-03-06 19:42:42 +09:00
Junegunn Choi 0b08f0dea0 Fix preview follow/scroll with long wrapped lines
Fixes bugs reported in https://github.com/junegunn/fzf/pull/4703:

* Clamp followOffset return value to avoid going past the end of lines
* Account for t.previewed.filled when determining scrollability
2026-03-06 19:21:22 +09:00
Junegunn Choi e7300fe300 Fix tab width when --frozen-left is used
https://github.com/junegunn/fzf/pull/4703#issuecomment-4004258816
2026-03-06 18:53:23 +09:00
dependabot[bot] 260d160973 Bump actions/labeler from 5 to 6 (#4700)
Bumps [actions/labeler](https://github.com/actions/labeler) from 5 to 6.
- [Release notes](https://github.com/actions/labeler/releases)
- [Commits](https://github.com/actions/labeler/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/labeler
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-02 23:58:37 +09:00
Laurent Cheylus d57ed157ad Remove tmppath pledge on OpenBSD (#4699)
"tmppath" pledge is no longer supported.
See commit https://github.com/openbsd/src/commit/c883e836f48a8ca9ee3be9de6ebf4b36751f2196

Signed-off-by: Laurent Cheylus <foxy@free.fr>
2026-03-02 22:55:13 +09:00
Junegunn Choi 9226bc605d Fix typos CI failure by excluding .s files 2026-03-02 22:49:54 +09:00
Junegunn Choi eacef5ea6e 0.70.0 2026-03-02 16:56:13 +09:00
Junegunn Choi 96eb68ce63 Add SIMD indexByteTwo/lastIndexByteTwo doc 2026-03-02 15:49:28 +09:00
Junegunn Choi 50be8bc78e Add fuzz tests for SIMD indexByteTwo to CI 2026-03-02 12:23:01 +09:00
Junegunn Choi b4e585779a Fix nth attr merge order to respect precedence hierarchy (#4697)
* Fix nth attr merge order to respect precedence hierarchy

nth attrs were merged ON TOP of current-fg/selected-fg attrs, so
nth:regular would clear attrs like underline from current-fg. Fix the
merge chain to apply nth BEFORE the line-type overlay:

  fg < nth < selected-fg < current-fg < hl < selected-hl < current-hl

Store raw current-fg and selected-fg attrs in ColorTheme before they
get merged with fg/ListFg, then pass them as nthOverlay through
printHighlighted to colorOffsets where the correct merge chain is
computed: fgAttr.Merge(nthAttr).Merge(nthOverlay).

Fix #4687

* Make current-fg inherit from list-fg instead of fg

current-fg was inheriting from fg, not list-fg, so setting list-fg
had no effect on the current line. e.g.

  fzf --color fg:dim,list-fg:underline,nth:regular -d / --nth -1

The non-nth part of the current line was dim (from fg) instead of
underline (from list-fg). Resolve ListFg/ListBg earlier in InitTheme
so Current can inherit from them.

* Make selected-fg inherit from list-fg via merge instead of override

selected-fg used o() which replaces the attr from list-fg entirely.
e.g. with fg:dim,selected-fg:italic, the dim was lost on selected
lines because o() replaced dim with italic instead of merging them.
Use ColorAttr.Merge() so attrs are combined additively, consistent
with how current-fg inherits from list-fg.

* Apply selected-fg attrs on current line when item is selected

When an item is both current and selected, the current-line rendering
ignored selected-fg attrs entirely. e.g.

  echo foo | fzf --color fg:dim,nth:regular,current-fg:underline,selected-fg:italic --bind result:select --multi

The italic from selected-fg was lost. Merge NthSelectedAttr into the
overlay and rebuild colBase attr with the correct precedence chain:
fg < selected-fg < current-fg.
2026-03-02 12:00:21 +09:00
Junegunn Choi 97ac7794cf Add SIMD indexByteTwo/lastIndexByteTwo for faster prefiltering
Use SIMD to search for two bytes simultaneously, replacing the
two-pass bytes.IndexByte approach in trySkip and the scalar backward
loop in asciiFuzzyIndex. AVX2+SSE2 on amd64, NEON on arm64, with
scalar fallback for other architectures.

=== query: 'l' ===
  [all]   baseline:   100.61ms  current:    98.88ms  (+1.7%)  matches: 5069891 (94.78%)
  [1T]    baseline:   889.28ms  current:   852.71ms  (+4.1%)  matches: 5069891 (94.78%)

=== query: 'lin' ===
  [all]   baseline:   281.31ms  current:   269.35ms  (+4.3%)  matches: 3516507 (65.74%)
  [1T]    baseline:  2266.51ms  current:  2238.24ms  (+1.2%)  matches: 3516507 (65.74%)

=== query: 'linux' ===
  [all]   baseline:    69.94ms  current:    68.33ms  (+2.3%)  matches: 307229 (5.74%)
  [1T]    baseline:   642.66ms  current:   589.10ms  (+8.3%)  matches: 307229 (5.74%)

=== query: 'linuxlinux' ===
  [all]   baseline:    39.56ms  current:    35.48ms  (+10.3%)  matches: 12230 (0.23%)
  [1T]    baseline:   367.88ms  current:   333.49ms  (+9.3%)  matches: 12230 (0.23%)

=== query: 'linuxlinuxlinux' ===
  [all]   baseline:    36.22ms  current:    31.59ms  (+12.8%)  matches: 865 (0.02%)
  [1T]    baseline:   339.48ms  current:   293.02ms  (+13.7%)  matches: 865 (0.02%)
2026-03-02 10:40:42 +09:00
Darren Bishop 4866c34361 Replace printf with builtin printf to by pass local indirections (#4684)
On macos, having run `brew install coreutils`, which installed GNU version of `printf`, this script/completion would sometimes complain about the (now missing) `-v` option usage.

This change set ensures the `-v` option is available where needed.

Note: there is a precedent of qualify which tool to run e.g. `command find ...`, `command dirname ...`, etc, so hopefully `builtin printf ...` should not cause any offense.
2026-03-01 19:13:16 +09:00
Junegunn Choi 3cfee281b4 Add change-with-nth action to dynamically change --with-nth (#4691) 2026-03-01 16:09:54 +09:00
Junegunn Choi 5887edc6ba Use LSD radix sort for Result sorting in matcher
Replace comparison-based pdqsort with LSD radix sort on the uint64
sort key. Radix sort is O(n) vs O(n log n) and avoids pointer-chasing
cache misses in the comparison function. Sort scratch buffer is reused
across iterations to reduce GC pressure.

Benchmark (single-threaded, Chromium file list):
- linux query (180K matches): ~16% faster
- src query (high match count): ~31% faster
- Rare matches: equivalent (falls back to pdqsort for n < 128)
2026-03-01 15:57:39 +09:00
Junegunn Choi 3e751c4e87 Add direct algo fast path in matchChunk
For the common case of a single fuzzy term with no nth transform,
call the algo function directly from matchChunk, bypassing the
MatchItem -> extendedMatch -> iter dispatch chain. This eliminates
3 function calls and the per-match []Offset heap allocation.
2026-03-01 15:57:39 +09:00
Junegunn Choi 8452c78cc8 Return Result by value from MatchItem 2026-03-01 15:57:39 +09:00
Junegunn Choi 2db14b4308 Enhance --bench output with formatted times, match count, and selectivity 2026-03-01 11:16:52 +09:00
junegunn 90c4269d4e Deploying to master from @ junegunn/fzf@6087055305 🚀 2026-02-28 15:01:49 +00:00
Junegunn Choi 6087055305 Enable uint64 compareRanks on arm64
Extend the uint64 rank comparison trick (comparing [4]uint16 as a
single uint64) to arm64 builds. ARM64 is little-endian like x86, so
the same unsafe.Pointer cast produces correct lexicographic ordering.

This replaces a 4-iteration loop with a single uint64 comparison,
speeding up the sort phase.

Chromium file list, single-threaded:
  linux:  126ms -> 126ms (sort not dominant)
  src:    462ms -> 438ms (-5%, sort-heavy)
2026-02-28 14:42:28 +09:00
Junegunn Choi 2f9df91171 Add --threads option to control matcher concurrency
By default, fzf uses 8 * NumCPU goroutines (capped at 32) for
parallel matching. --threads N overrides this to use exactly N
goroutines, which is useful for benchmarking and profiling.
2026-02-26 14:51:59 +09:00
Junegunn Choi 12e24d368c Add --bench flag for repeatable filter-mode timing
fzf --filter PATTERN --bench 3s < input

Repeats matcher.scan() for the given duration, clears cache between
iterations, and prints stats (iterations, avg, min, max) to stderr.
2026-02-25 10:16:37 +09:00
Junegunn Choi 55193ee4dc Fix double subtraction of header lines from FZF_TOTAL_COUNT
Close #4692
2026-02-25 00:50:47 +09:00
Junegunn Choi ff6a3bbee0 Add GitHub action for labelling PRs 2026-02-24 20:27:29 +09:00
Junegunn Choi dce248ac6d Revert "Add GitHub action for labelling PRs"
This reverts commit 0ff13dcf.
2026-02-24 20:26:39 +09:00
Junegunn Choi 0ff13dcfbe Add GitHub action for labelling PRs 2026-02-24 20:20:04 +09:00
Junegunn Choi 4d6a7757b8 Fix adaptive height calculation to exclude header lines
The adaptive height (--height ~100%) was using the raw chunklist count
including header items, making the window too tall by headerLines rows.
2026-02-23 02:21:41 +09:00
Junegunn Choi b9804f5873 Add change-header-lines action to dynamically change --header-lines
All input lines now enter the chunklist with sequential indices, and
header lines are excluded from matching via Pattern.startIndex and
PassMerger offset. This allows the number of header lines to be changed
at runtime with change-header-lines(N), transform-header-lines, and
bg-transform-header-lines actions.

- Remove EvtHeader event; header items are read directly from chunks
- Add startIndex to Pattern and PassMerger for skipping header items
- Add targetIndex field to Terminal for cursor repositioning across
  header-lines changes

Close #4659
2026-02-23 01:48:03 +09:00
Junegunn Choi 98a3b1fff8 Revert "Skip dead zones in FuzzyMatchV2 score matrix computation"
This reverts commit 6df5ca17e8.
2026-02-23 01:48:03 +09:00
Junegunn Choi 6df5ca17e8 Skip dead zones in FuzzyMatchV2 score matrix computation
In Phase 3 of FuzzyMatchV2, when a cell's left neighbor score is <= 1
and the current character doesn't match the pattern character, the
cell's score is guaranteed to be 0 (since gap penalties are -1 and -3).
Skip the bonus/gap computation entirely and fast-forward through
consecutive non-matching characters in the dead zone.

This yields 6-11% faster fuzzy searches on typical workloads.
2026-02-22 03:09:29 +09:00
Junegunn Choi 09ca45f7db Increase chunkSize from 100 to 1000 to reduce lock contention
With chunkSize=100 and 10M items, 100K chunks cause ~300K mutex
lock/unlock operations per search across 32 goroutines competing
for a single sync.Mutex in ChunkCache.

Increasing to 1000 reduces chunks to 10K, cutting contention overhead.
Benchmarks on 10M items show 14-80% faster searches depending on query
selectivity.
2026-02-22 03:09:28 +09:00
junegunn 09fe3a4180 Deploying to master from @ junegunn/fzf@b908f7a0ec 🚀 2026-02-21 15:03:44 +00:00
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
Junegunn Choi 2ab923f3ae 0.67.0 2025-11-16 20:02:39 +09:00
Massimo Mund c3e6d9a8f9 Distinguish between Ctrl-H and Ctrl-Backspace in Windows (#4590)
Since you can actually distinguish between Ctrl-H and Ctrl-Backspace in Windows we need to reintroduce the tui.CtrlH constant. On *nix systems we map all Ctrl(-Alt)-h to Ctrl(-Alt)-Backspace internally, but you can use either in --bind.
2025-11-16 20:00:24 +09:00
Junegunn Choi 2471edf3ff Make ctrl-alt-h a synonym of ctrl-alt-backspace on non-Windows environment (#4589) 2025-11-16 16:33:53 +09:00
junegunn 53a8aeeb72 Deploying to master from @ junegunn/fzf@60b35e748b 🚀 2025-11-16 00:02:13 +00:00
Junegunn Choi 60b35e748b Header and footer should not be wider than the list
Example:
  WIDE=$(printf 'x%.0s' {1..1000})
  (echo $WIDE; echo $WIDE) |
    fzf --header-lines 1 --style full --ellipsis XX --header "$WIDE" \
        --no-header-lines-border --footer "$WIDE" --no-footer-border
2025-11-15 11:41:51 +09:00
Junegunn Choi 3f499f055e Avoid truncating ellipsis to avoid confusion 2025-11-13 23:00:32 +09:00
Junegunn Choi 1df99db0b2 Keep the previous delimiter before frozen columns 2025-11-13 22:38:49 +09:00
dependabot[bot] 535b610a6b Bump github/codeql-action from 3 to 4 (#4554)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3 to 4.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-13 10:48:43 +09:00
phanium 91fab3b3c2 Fix lint warnings (#4586)
go run golang.org/x/tools/gopls/internal/analysis/modernize/cmd/modernize@latest -fix -test ./...
2025-11-12 22:05:17 +09:00
Junegunn Choi b9f2bf64ff Add --freeze-right=N option to keep the rightmost N fields visible 2025-11-12 22:00:27 +09:00
Junegunn Choi 07d53cb7e4 Add --freeze-left=N option to keep the leftmost N fields visible 2025-11-12 22:00:27 +09:00
Massimo Mund ead534a1be Fix modifier detection for Backspace / Ctrl-H on Windows (#4582)
Windows sends different key events and modifier combinations to theFullscreenRenderer than a tcell FullscreenRenderer on Linux (-tags tcell).
This led to Ctrl+H being misinterpreted (and therefore unbindable) on some Windows builds.

Basically reverts changes to `src/tui/tcell.go` introduced by `a0cabe0`.
2025-11-10 19:12:01 +09:00
Junegunn Choi 8a05083503 Fix reading an extra key after a terminal action
Fix #4578
2025-11-09 15:36:07 +09:00
phanium e659b46ff5 feat: append spinner in the end when --info=inline (#4567)
Test:
  go run main.go --query "$(seq 100)" --info inline --border < <(sleep 60)
  go run main.go --query "$(seq 100)" --info inline --info-command 'echo hello' --border < <(sleep 60)

Close #4344
Close #619

Co-authored-by: Junegunn Choi <junegunn.c@gmail.com>
2025-11-09 10:44:27 +09:00
Junegunn Choi 991c36453c [man] Add --gutter-raw
Close #4579
2025-11-08 17:50:25 +09:00
junegunn 4d563c6dfa Deploying to master from @ junegunn/fzf@5cb695744f 🚀 2025-11-02 00:02:16 +00:00
Koichi Murase 5cb695744f [bash,zsh] Fix the version check for mawk (#4574)
We have been checking the mawk version by extracting <x>, <y>, <z>,
and <d> part from "mawk <x>.<y>.<z> <d>" in the output of the "mawk -W
version" and testing <x>, <y>, <z>, and <d> using an arithmetic
evalaution.  However, <d> is ensured to be an integer only in "x.y.z
>= 1.3.4".  Otherwise, it may cause a syntax error in the arithmetic
evaluation.  The mawk started to include the date as an integer in the
<d> position only from mawk-1.3.3-20090721.  We should first check
that "x.y.z >= 1.3.4" and then check the value of "d".  In case, "mawk
-W version" produces a completely different text, we should also
redirect stderr of the arithmetic commands to /dev/null.
2025-10-31 21:14:41 +09:00
110 changed files with 10442 additions and 2482 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
root = true
[*.{sh,bash}]
[*.{sh,bash,fish}]
indent_style = space
indent_size = 2
simplify = true
+1
View File
@@ -0,0 +1 @@
* @junegunn
+17
View File
@@ -0,0 +1,17 @@
## Contribution Policy
We do not accept pull requests generated primarily by AI without genuine understanding or real-world usage context.
All contributions are expected to demonstrate:
- A clear understanding of the codebase
- Alignment with product direction
- Thoughtful reasoning behind changes
- Evidence of real-world usage or hands-on experience with the problem
If these expectations are not met, we would prefer to implement the changes ourselves rather than spend time reviewing low-effort submissions.
---
## Acknowledgement
- [ ] I confirm that this PR meets the above expectations and reflects my own understanding and real-world context.
+64
View File
@@ -0,0 +1,64 @@
go:
- changed-files:
- any-glob-to-any-file:
- src/**
- main.go
- go.mod
- go.sum
shell:
- changed-files:
- any-glob-to-any-file:
- shell/**
bash:
- changed-files:
- any-glob-to-any-file:
- shell/**/*.bash
zsh:
- changed-files:
- any-glob-to-any-file:
- shell/**/*.zsh
fish:
- changed-files:
- any-glob-to-any-file:
- shell/**/*.fish
vim:
- changed-files:
- any-glob-to-any-file:
- plugin/**
docs:
- changed-files:
- any-glob-to-any-file:
- '*.md'
- doc/**
- man/**
ci:
- changed-files:
- any-glob-to-any-file:
- .github/**
build:
- changed-files:
- any-glob-to-any-file:
- Makefile
- .goreleaser.yml
- Dockerfile
test:
- changed-files:
- any-glob-to-any-file:
- test/**
- src/**/*_test.go
install:
- changed-files:
- any-glob-to-any-file:
- install
- install.ps1
- uninstall
+3 -3
View File
@@ -33,12 +33,12 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@v3
uses: github/codeql-action/autobuild@v4
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
uses: github/codeql-action/analyze@v4
+1 -1
View File
@@ -11,4 +11,4 @@ jobs:
- name: 'Checkout Repository'
uses: actions/checkout@v5
- name: 'Dependency Review'
uses: actions/dependency-review-action@v4
uses: actions/dependency-review-action@v5
+17
View File
@@ -0,0 +1,17 @@
name: Label PRs
on:
pull_request_target:
types: [opened, synchronize, reopened]
permissions:
contents: read
pull-requests: write
jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v6
with:
configuration-path: .github/labeler.yml
+13 -3
View File
@@ -5,7 +5,7 @@ on:
push:
branches: [ master, devel ]
pull_request:
branches: [ master ]
branches: [ master, devel ]
workflow_dispatch:
permissions:
@@ -28,12 +28,17 @@ jobs:
go-version: "1.23"
- name: Setup Ruby
uses: ruby/setup-ruby@v1
uses: ruby/setup-ruby@97ecb7b512899eb71ab1bf2310a624c6f1589ac6 # v1
with:
ruby-version: 3.4.6
- name: Install packages
run: sudo apt-get install --yes zsh fish tmux shfmt
run: |
sudo install -d -m 0755 /etc/apt/keyrings
wget -qO- https://apt.fury.io/nushell/gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/fury-nushell.gpg
echo "deb [signed-by=/etc/apt/keyrings/fury-nushell.gpg] https://apt.fury.io/nushell/ /" | sudo tee /etc/apt/sources.list.d/fury-nushell.list
sudo apt-get update
sudo apt-get install --yes zsh fish tmux shfmt nushell
- name: Install Ruby gems
run: bundle install
@@ -44,5 +49,10 @@ jobs:
- name: Unit test
run: make test
- name: Fuzz test
run: |
go test ./src/algo/ -fuzz=FuzzIndexByteTwo -fuzztime=5s
go test ./src/algo/ -fuzz=FuzzLastIndexByteTwo -fuzztime=5s
- name: Integration test
run: make install && ./install --all && tmux new-session -d && ruby test/runner.rb --verbose
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
go-version: "1.23"
- name: Setup Ruby
uses: ruby/setup-ruby@v1
uses: ruby/setup-ruby@97ecb7b512899eb71ab1bf2310a624c6f1589ac6 # v1
with:
ruby-version: 3.0.0
+76
View File
@@ -0,0 +1,76 @@
name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
version:
description: 'Version to validate (e.g. 0.73.0).'
type: string
required: true
permissions:
contents: write
jobs:
release:
runs-on: macos-latest
environment: release
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/setup-go@v6
with:
go-version: stable
- name: Determine version
id: ver
run: |
if [ "${{ github.event_name }}" = "push" ]; then
v=${GITHUB_REF_NAME#v}
else
v='${{ inputs.version }}'
fi
echo "version=$v" >> "$GITHUB_OUTPUT"
echo "Resolved version: '$v'"
- name: Verify version consistency
run: |
set -e
V='${{ steps.ver.outputs.version }}'
R=$(echo "$V" | sed 's/\./\\./g')
grep -q "^${R}$" CHANGELOG.md
grep -qF "\"fzf ${V}\"" man/man1/fzf.1
grep -qF "\"fzf ${V}\"" man/man1/fzf-tmux.1
grep -qF "${V}" install
grep -qF "${V}" install.ps1
- name: Extract release notes
run: |
set -e
mkdir -p tmp
V='${{ steps.ver.outputs.version }}'
R=$(echo "$V" | sed 's/\./\\./g')
sed -n "/^${R}$/,/^[0-9]/p" CHANGELOG.md \
| tail -r | sed '1,/^ *$/d' | tail -r | sed '1,2d' \
| tee tmp/release-note
- name: Run goreleaser
uses: goreleaser/goreleaser-action@v7
with:
version: latest
args: >-
${{ github.event_name == 'push'
&& 'release --clean --release-notes tmp/release-note'
|| 'release --snapshot --clean --skip=publish' }}
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_PAT }}
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
-24
View File
@@ -1,24 +0,0 @@
---
name: Generate Sponsors README
on:
workflow_dispatch:
schedule:
- cron: 0 0 * * 0
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout 🛎️
uses: actions/checkout@v5
- name: Generate Sponsors 💖
uses: JamesIves/github-sponsors-readme-action@v1
with:
token: ${{ secrets.SPONSORS_TOKEN }}
file: 'README.md'
- name: Deploy to GitHub Pages 🚀
uses: JamesIves/github-pages-deploy-action@v4
with:
branch: master
folder: '.'
+1 -1
View File
@@ -7,4 +7,4 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: crate-ci/typos@v1.29.4
- uses: crate-ci/typos@685eb3d55be2f85191e8c84acb9f44d7756f84ab # v1.29.4
+8 -1
View File
@@ -2,13 +2,20 @@ name: Publish to Winget
on:
release:
types: [released]
workflow_dispatch:
inputs:
release-tag:
description: 'Release tag to submit (e.g. v0.73.1)'
required: true
type: string
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: vedantmgoyal2009/winget-releaser@v2
- uses: vedantmgoyal2009/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2
with:
identifier: junegunn.fzf
release-tag: ${{ inputs.release-tag || github.event.release.tag_name }}
installers-regex: '-windows_(armv7|arm64|amd64)\.zip$'
token: ${{ secrets.WINGET_TOKEN }}
+9 -4
View File
@@ -22,6 +22,7 @@ builds:
- loong64
- ppc64le
- s390x
- riscv64
goarm:
- "5"
- "6"
@@ -39,6 +40,8 @@ builds:
goarch: arm64
- goos: openbsd
goarch: arm64
- goos: openbsd
goarch: riscv64
- goos: android
goarch: amd64
- goos: android
@@ -82,12 +85,14 @@ notarize:
archives:
- name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
builds:
ids:
- fzf
format: tar.gz
formats:
- tar.gz
format_overrides:
- goos: windows
format: zip
formats:
- zip
files:
- non-existent*
@@ -99,7 +104,7 @@ release:
name_template: '{{ .Version }}'
snapshot:
name_template: "{{ .Version }}-devel"
version_template: "{{ .Version }}-devel"
changelog:
sort: asc
+8 -8
View File
@@ -309,16 +309,16 @@ I know it's a lot to digest, let's try to break down the code.
available color options.
- The value of `--preview-window` option consists of 5 components delimited
by `,`
1. `up` Position of the preview window
1. `60%` Size of the preview window
1. `border-bottom` Preview window border only on the bottom side
1. `+{2}+3/3` Scroll offset of the preview contents
1. `~3` Fixed header
1. `up` -- Position of the preview window
1. `60%` -- Size of the preview window
1. `border-bottom` -- Preview window border only on the bottom side
1. `+{2}+3/3` -- Scroll offset of the preview contents
1. `~3` -- Fixed header
- Let's break down the latter two. We want to display the bat output in the
preview window with a certain scroll offset so that the matching line is
positioned near the center of the preview window.
- `+{2}` The base offset is extracted from the second token
- `+3` We add 3 lines to the base offset to compensate for the header
- `+{2}` -- The base offset is extracted from the second token
- `+3` -- We add 3 lines to the base offset to compensate for the header
part of `bat` output
- ```
───────┬──────────────────────────────────────────────────────────
@@ -363,7 +363,7 @@ projects, and it will free up memory as you narrow down the results.
RG_PREFIX="rg --column --line-number --no-heading --color=always --smart-case "
INITIAL_QUERY="${*:-}"
fzf --ansi --disabled --query "$INITIAL_QUERY" \
--bind "start:reload:$RG_PREFIX {q}" \
--bind "start:reload:$RG_PREFIX {q} || true" \
--bind "change:reload:sleep 0.1; $RG_PREFIX {q} || true" \
--delimiter : \
--preview 'bat --color=always {1} --highlight-line {2}' \
+228
View File
@@ -1,6 +1,234 @@
CHANGELOG
=========
0.73.1
------
- Bug fixes
- Skip `$FZF_CURRENT_ITEM` export when the item contains a NUL byte; `exec(2)` rejects the env, breaking preview and other child commands (#4806)
- Fixed O(n^2) HTTP body accumulation in `--listen`; a single ~390 KB request could block the single-threaded server for ~8 s (Michal Majchrowicz, Marcin Wyczechowski, AFINE Team)
0.73.0
------
_Release highlights: https://junegunn.github.io/fzf/releases/0.73.0/_
- Nushell integration via `fzf --nushell` and the installer (#4630) (@sim590)
- New `--preview-window=next` position that places the preview adjacent to the input section, on the list side: above the input in the default layout, below it in `--layout=reverse` (#4798)
- Timer-driven `every(N)` event for `--bind`, where `N` is seconds
- Added `$FZF_IDLE_TIME` (whole seconds) and `$FZF_IDLE_TIME_MS` (milliseconds), holding the elapsed time since the last user activity
- Pair with `every(N)` to build idle-based behavior such as auto-accept or auto-quit (#1211)
```sh
# Live process list; --track --id-nth 2 keeps the cursor on the same PID across reloads
fzf --header-lines 1 --track --id-nth 2 --bind 'start,every(2):reload-sync:ps -ef'
# Auto-accept after 10 seconds of inactivity, with a countdown in the footer after 5s
fzf --bind 'every(1):bg-transform:
if [[ $FZF_IDLE_TIME -lt 5 ]]; then echo change-footer:
elif [[ $FZF_IDLE_TIME -lt 10 ]]; then echo "change-footer:auto-accept in $((10 - FZF_IDLE_TIME))s"
else echo accept
fi'
```
- Added `$FZF_CURRENT_ITEM` for shells where quoting `{}` is awkward (#4802)
- Bug fixes
- Scoring: non-word characters at the start of input or after a delimiter now receive the same boundary bonus as word characters (#4795)
- `change-preview-window` no longer resets `wrap` / `wrap-word` state set via `toggle-preview-wrap` / `toggle-preview-wrap-word` (#4791)
- Stripped UTF-8-encoded C1 control characters from rendered items to prevent terminal control-sequence injection
- Fixed integer-overflow panic in `FuzzyMatchV2` on 32-bit builds (Michal Majchrowicz, Marcin Wyczechowski, AFINE Team)
- Fixed `bg-transform` `reload` / `exclude` payloads being dropped
- Fixed rendering glitch with preview window on the left combined with footer
0.72.0
------
_Release highlights: https://junegunn.github.io/fzf/releases/0.72.0/_
- `--header-border`, `--header-lines-border`, and `--footer-border` now accept a new `inline` style that embeds the section inside the list frame, separated from the list content by a horizontal line. When the list border has side segments, the separator joins them as T-junctions.
- Requires a `--list-border` shape that has both top and bottom segments (`rounded`, `sharp`, `bold`, `double`, `block`, `thinblock`, or `horizontal`); falls back to `line` otherwise. `horizontal` has no side borders, so the separator is drawn without T-junction endpoints.
- Sections stack. Example combining all three:
```sh
ps -ef | fzf --reverse --style full \
--header 'Select a process' --header-lines 1 \
--bind 'load:transform-footer:echo $FZF_TOTAL_COUNT processes' \
--header-border dashed --header-first \
--header-lines-border inline --footer-border inline
```
- `--header-label` and `--footer-label` render on their respective separator row.
- The separator inherits `--color list-border` when the section's own border color is not explicitly set.
- `inline` takes precedence over `--header-first`: the inline section stays inside the list frame. `--header-border=inline` requires `--header-lines-border` to be `inline` or unset.
- New `dashed` border style with dashed edges (`` / ``) and rounded corners.
- `--border=dashed`, `--list-border=dashed`, etc.
- Works with inline sections (T-junctions render correctly).
- [vim] Move and resize popup window when detecting `VimResized` event (#4778) (@Vulcalien)
- Bug fixes
- Fixed gutter display in `--style=minimal`
- Fixed arrow keys / Home / End without modifiers being ignored under the kitty keyboard protocol (#4776) (@TymekDev)
- bash: Persist history deletion when `histappend` is on (#4764)
0.71.0
------
_Release highlights: https://junegunn.github.io/fzf/releases/0.71.0/_
- Added `--popup` as a new name for `--tmux` with Zellij support
- `--popup` starts fzf in a tmux popup or a Zellij floating pane
- `--tmux` is now an alias for `--popup`
- Requires tmux 3.3+ or Zellij 0.44+
- Cross-reload item identity with `--id-nth`
- Added `--id-nth=NTH` to define item identity fields for cross-reload operations
- When a `reload` is triggered with tracking enabled, fzf searches for the tracked item by its identity fields in the new list.
- `--track --id-nth ..` tracks by the entire line
- `--track --id-nth 1` tracks by the first field
- `--track` without `--id-nth` retains the existing index-based tracking behavior
- The UI is temporarily blocked (prompt dimmed, input disabled) until the item is found or loading completes.
- Press `Escape` or `Ctrl-C` to cancel the blocked state without quitting
- Info line shows `+T*` / `+t*` while searching
- With `--multi`, selected items are preserved across `reload-sync` by matching their identity fields
- Performance improvements
- The search performance now scales linearly with the number of CPU cores, as we dropped static partitioning to allow better load balancing across threads.
```
=== query: 'linux' ===
[all] baseline: 21.95ms current: 17.47ms (1.26x) matches: 179966 (12.79%)
[1T] baseline: 179.63ms current: 180.53ms (1.00x) matches: 179966 (12.79%)
[2T] baseline: 97.38ms current: 90.05ms (1.08x) matches: 179966 (12.79%)
[4T] baseline: 53.83ms current: 44.77ms (1.20x) matches: 179966 (12.79%)
[8T] baseline: 41.66ms current: 22.58ms (1.84x) matches: 179966 (12.79%)
```
- Improved the cache structure, reducing memory footprint per entry by 86x.
- With the reduced per-entry cost, the cache now has broader coverage.
- Shell integration improvements
- bash: CTRL-R now supports multi-select and `shift-delete` to delete history entries (#4715)
- fish:
- Improved command history (CTRL-R) (#4703) (@bitraid)
- Rewrite completion script (SHIFT-TAB) (#4731) (@bitraid)
- Increase minimum fish version requirement to 3.4.0 (#4731) (@bitraid)
- `GET /` HTTP endpoint now includes `positions` field in each match entry, providing the indices of matched characters for external highlighting (#4726)
- Allow adaptive height with negative value (`--height=~-HEIGHT`) (#4682)
- Bug fixes
- `--walker=follow` no longer follows symlinks whose target is an ancestor of the walker root, avoiding severe resource exhaustion when a symlink points outside the tree (e.g. Wine's `z:` → `/`) (#4710)
- Fixed AWK tokenizer not treating a new line character as whitespace
- Fixed `--{accept,with}-nth` removing trailing whitespaces with a non-default `--delimiter`
- Fixed OSC8 hyperlinks being mangled when the URL contains unicode characters (#4707)
- Fixed `--with-shell` not handling quoted arguments correctly (#4709)
- Fixed child processes not being terminated on Windows (#4723) (@pjeby)
- Fixed preview scrollbar not rendered after `toggle-preview`
- Fixed preview follow/scroll with long wrapped lines
- Fixed tab width when `--frozen-left` is used
- Fixed preview mouse events being processed when no preview window exists
- zsh: Fixed history widget when `sh_glob` option is on (#4714) (@EvanHahn)
0.70.0
------
- Added `change-with-nth` action for dynamically changing the `--with-nth` option.
- Requires `--with-nth` to be set initially.
- Multiple options separated by `|` can be given to cycle through.
```sh
echo -e "a b c\nd e f\ng h i" | fzf --with-nth .. \
--bind 'space:change-with-nth(1|2|3|1,3|2,3|)'
```
- Added `change-header-lines` action for dynamically changing the `--header-lines` option
- Performance improvements (1.3x to 1.9x faster filtering depending on query)
```
=== query: 'l' ===
[all] baseline: 168.87ms current: 95.21ms (1.77x) matches: 5069891 (94.78%)
[1T] baseline: 1652.22ms current: 841.40ms (1.96x) matches: 5069891 (94.78%)
=== query: 'lin' ===
[all] baseline: 343.27ms current: 252.59ms (1.36x) matches: 3516507 (65.74%)
[1T] baseline: 3199.89ms current: 2230.64ms (1.43x) matches: 3516507 (65.74%)
=== query: 'linux' ===
[all] baseline: 85.47ms current: 63.72ms (1.34x) matches: 307229 (5.74%)
[1T] baseline: 774.64ms current: 589.32ms (1.31x) matches: 307229 (5.74%)
=== query: 'linuxlinux' ===
[all] baseline: 55.13ms current: 35.67ms (1.55x) matches: 12230 (0.23%)
[1T] baseline: 461.99ms current: 332.38ms (1.39x) matches: 12230 (0.23%)
=== query: 'linuxlinuxlinux' ===
[all] baseline: 51.77ms current: 32.53ms (1.59x) matches: 865 (0.02%)
[1T] baseline: 409.99ms current: 296.33ms (1.38x) matches: 865 (0.02%)
```
- Fixed `nth` attribute merge order to respect precedence hierarchy (#4697)
- bash: Replaced `printf` with builtin `printf` to bypass local indirections (#4684) (@DarrenBishop)
0.68.0
------
- Implemented word wrapping in the list section
- Added `--wrap=word` (or `--wrap-word`) option and `toggle-wrap-word` action for word-level line wrapping in the list section
- Changed default binding of `ctrl-/` and `alt-/` from `toggle-wrap` to `toggle-wrap-word`
```sh
fzf --wrap=word
```
- Implemented word wrapping in the preview window
- Added `wrap-word` flag for `--preview-window` to enable word-level wrapping
- Added `toggle-preview-wrap-word` action
```sh
fzf --preview 'bat --style=plain --color=always {}' \
--preview-window wrap-word \
--bind space:toggle-preview-wrap-word
```
- Added support for underline style variants in `--color`: `underline-double`, `underline-curly`, `underline-dotted`, `underline-dashed`
```sh
fzf --color 'fg:underline-curly,current-fg:underline-dashed'
```
- Added support for underline styles (`4:N`) and underline colors (SGR 58/59)
```sh
# In the list section
printf '\e[4:3;58;2;255;0;0mRed curly underline\e[0m\n' | fzf --ansi
# In the preview window
fzf --preview "printf '\e[4:3;58;2;255;0;0mRed curly underline\e[0m\n'"
```
- Added `--preview-wrap-sign` to set a different wrap indicator for the preview window
- Added `alt-gutter` color option (#4602) (@hedgieinsocks)
- Added `$FZF_WRAP` environment variable to child processes (`char` or `word` when wrapping is enabled) (#4672) (@bitraid)
- fish: Improved command history (CTRL-R) (#4672) (@bitraid)
- Enabled syntax highlighting in the list on fish 4.3.3+
- Added syntax-highlighted preview window that auto-shows for long or multi-line commands
- Added `ALT-ENTER` to reformat and insert selected commands
- Improved handling of bulk deletion of selected history entries (`SHIFT-DELETE`)
- Added fish completion support (#4605) (@lalvarezt)
- zsh: Handle multi-line history selection (#4595) (@LangLangBart)
- Bug fixes
- Fixed `_fzf_compgen_{path,dir}` to respect `FZF_COMPLETION_{PATH,DIR}_OPTS` (#4592) (@shtse8, @LangLangBart)
- Fixed `--preview-window follow` not working correctly with wrapping (#3243, #4258)
- Fixed symlinks to directories being returned as files (#4676) (@skk64)
- Fixed SIGHUP signal handling (#4668) (@LangLangBart)
- Fixed preview process not killed on exit (#4667)
- Fixed coloring of items with zero-width characters (#4620)
- Fixed `track-current` unset after a combined movement action (#4649)
- Fixed `--accept-nth` being ignored in filter mode (#4636) (@charemma)
- Fixed display width calculation with `maxWidth` (#4596) (@LangLangBart)
- Fixed clearing of the rest of the current line on start (#4652)
- Fixed `x-api-key` header not required for GET requests (#4627)
- Fixed key reading not cancelled when `execute` triggered via a server request (#4653)
- Fixed rebind of readline command `redraw-current-line` (#4635) (@jameslazo)
- Fixed `fzf-tmux` `TERM` quoting and added `mktemp` usage (#4664) (@Goofygiraffe06)
- Do not allow very long queries in `FuzzyMatchV2` (#4608)
0.67.0
------
- Added `--freeze-left=N` option to keep the leftmost N columns always visible.
```sh
# Keep the file name column fixed and always visible
git grep --line-number --color=always -- '' |
fzf --ansi --delimiter : --freeze-left 1
# Can be used with --keep-right
git grep --line-number --color=always -- '' |
fzf --ansi --delimiter : --freeze-left 1 --keep-right
```
- Also added `--freeze-right=N` option to keep the rightmost N columns always visible.
```sh
# Stronger version of --keep-right that always keeps the right-end visible
fd | fzf --freeze-right 1
# Keep the base name always visible
fd | fzf --freeze-right 1 --delimiter /
# Keep both leftmost and rightmost components visible
fd | fzf --freeze-left 1 --freeze-right 1 --delimiter /
```
- Updated `--info=inline` to print the spinner (load indicator).
- Bug fixes
0.66.1
------
- Bug fixes
+11 -1
View File
@@ -1,5 +1,15 @@
FROM rubylang/ruby:3.4.1-noble
RUN apt-get update -y && apt install -y git make golang zsh fish tmux
RUN apt-get update && apt-get install -y git make golang zsh fish tmux
# https://www.nushell.sh/book/installation.html
RUN <<EOF
set -ex
apt-get install -y wget gnupg
wget -qO- https://apt.fury.io/nushell/gpg.key | gpg --dearmor -o /etc/apt/keyrings/fury-nushell.gpg
echo "deb [signed-by=/etc/apt/keyrings/fury-nushell.gpg] https://apt.fury.io/nushell/ /" | tee /etc/apt/sources.list.d/fury-nushell.list
apt-get update
apt-get install -y nushell
EOF
RUN gem install --no-document -v 5.22.3 minitest
RUN echo '. /usr/share/bash-completion/completions/git' >> ~/.bashrc
RUN echo '. ~/.bashrc' >> ~/.bash_profile
+1 -1
View File
@@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2013-2025 Junegunn Choi
Copyright (c) 2013-2026 Junegunn Choi
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+21 -5
View File
@@ -1,4 +1,5 @@
GO ?= go
DOCKER ?= docker
GOOS ?= $(shell $(GO) env GOOS)
MAKEFILE := $(realpath $(lastword $(MAKEFILE_LIST)))
@@ -52,6 +53,8 @@ ifeq ($(UNAME_M),x86_64)
BINARY := $(BINARY64)
else ifeq ($(UNAME_M),amd64)
BINARY := $(BINARY64)
else ifeq ($(UNAME_M),i86pc)
BINARY := $(BINARY64)
else ifeq ($(UNAME_M),s390x)
BINARY := $(BINARYS390)
else ifeq ($(UNAME_M),i686)
@@ -114,6 +117,19 @@ generate:
build:
goreleaser build --clean --snapshot --skip=post-hooks
prerelease:
# Check if version numbers are properly updated
grep -q ^$(VERSION_REGEX)$$ CHANGELOG.md
grep -qF '"fzf $(VERSION_TRIM)"' man/man1/fzf.1
grep -qF '"fzf $(VERSION_TRIM)"' man/man1/fzf-tmux.1
grep -qF $(VERSION) install
grep -qF $(VERSION) install.ps1
@echo "OK: all files consistent at $(VERSION)"
tag: prerelease
git tag -s v$(VERSION) -m v$(VERSION)
git push origin v$(VERSION)
release:
# Make sure that the tests pass and the build works
TAGS=tcell make test
@@ -192,15 +208,15 @@ bin/fzf: target/$(BINARY) | bin
cp -f target/$(BINARY) bin/fzf
docker:
docker build -t fzf-ubuntu .
docker run -it fzf-ubuntu tmux
$(DOCKER) build -t fzf-ubuntu .
$(DOCKER) run -it fzf-ubuntu tmux
docker-test:
docker build -t fzf-ubuntu .
docker run -it fzf-ubuntu
$(DOCKER) build -t fzf-ubuntu .
$(DOCKER) run -it fzf-ubuntu
update:
$(GO) get -u
$(GO) mod tidy
.PHONY: all generate build release test itest bench lint install clean docker docker-test update fmt
.PHONY: all generate build prerelease tag release test itest bench lint install clean docker docker-test update fmt
+1 -1
View File
@@ -493,4 +493,4 @@ autocmd FileType fzf set laststatus=0 noshowmode noruler
The MIT License (MIT)
Copyright (c) 2013-2025 Junegunn Choi
Copyright (c) 2013-2026 Junegunn Choi
+215 -71
View File
File diff suppressed because one or more lines are too long
+54
View File
@@ -0,0 +1,54 @@
Release process
===============
Building, signing, notarizing, and publishing is handled by
[`.github/workflows/release.yml`](.github/workflows/release.yml),
triggered by a tag push.
## Steps
1. Update version in the following files and commit on `master`:
- `CHANGELOG.md`
- `main.go`
- `install`
- `install.ps1`
- `man/man1/fzf.1`
- `man/man1/fzf-tmux.1`
2. Verify file consistency, sign the tag, and push the tag.
```sh
make tag VERSION=0.73.1
```
`make tag` runs `prerelease` first (checks that the version
appears in CHANGELOG.md, both man pages, install, and install.ps1)
and only signs + pushes the tag if the checks pass.
Only the tag is pushed; `master` on origin still points to the
old version, so `/master/install` keeps resolving against existing
binaries during the publish window.
3. The workflow fires on the tag push and pauses on the `release`
environment gate. Approve it in the Actions tab to release.
4. After the GitHub release is published, fast-forward `master`:
```sh
git push origin master
```
## Testing the workflow
To exercise the workflow without firing a real release:
1. Actions tab -> **Release** -> **Run workflow**.
2. Pick a branch and enter the version currently on that branch
(the version-consistency check requires the input to match the
files in the checked-out tree).
3. Approve the `release` environment gate when prompted.
4. Goreleaser runs with `--snapshot --skip=publish`. Signing and
notarization run; only the GitHub release upload is skipped.
Use this to validate the workflow YAML, version-extraction logic,
the macOS runner setup, and the signing/notarization credentials.
+7 -8
View File
@@ -159,11 +159,11 @@ fi
set -e
# Clean up named pipes on exit
id=$RANDOM
argsf="${TMPDIR:-/tmp}/fzf-args-$id"
fifo1="${TMPDIR:-/tmp}/fzf-fifo1-$id"
fifo2="${TMPDIR:-/tmp}/fzf-fifo2-$id"
fifo3="${TMPDIR:-/tmp}/fzf-fifo3-$id"
tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/fzf-tmux-XXXXXX")
argsf="$tmpdir/args"
fifo1="$tmpdir/fifo1"
fifo2="$tmpdir/fifo2"
fifo3="$tmpdir/fifo3"
if tmux_win_opts=$(tmux show-options -p remain-on-exit \; show-options -p synchronize-panes 2> /dev/null); then
tmux_win_opts=($(sed '/ off/d; s/synchronize-panes/set-option -p synchronize-panes/; s/remain-on-exit/set-option -p remain-on-exit/; s/$/ \\;/' <<< "$tmux_win_opts"))
tmux_off_opts='; set-option -p synchronize-panes off ; set-option -p remain-on-exit off'
@@ -172,8 +172,7 @@ else
tmux_off_opts='; set-window-option synchronize-panes off ; set-window-option remain-on-exit off'
fi
cleanup() {
\rm -f $argsf $fifo1 $fifo2 $fifo3
\rm -rf "$tmpdir"
# Restore tmux window options
if [[ ${#tmux_win_opts[@]} -gt 1 ]]; then
eval "tmux ${tmux_win_opts[*]}"
@@ -196,7 +195,7 @@ cleanup() {
trap 'cleanup 1' SIGUSR1
trap 'cleanup' EXIT
envs="export TERM=$TERM "
envs="export TERM=$(printf %q "$TERM") "
if [[ $opt =~ "-E" ]]; then
if [[ $tmux_version == 3.2 ]]; then
FZF_DEFAULT_OPTS="--margin 0,1 $FZF_DEFAULT_OPTS"
+3 -5
View File
@@ -112,7 +112,7 @@ the whole if we start off with `:FZF` command.
" Bang version starts fzf in fullscreen mode
:FZF!
<
Similarly to {ctrlp.vim}{3}, use enter key, CTRL-T, CTRL-X or CTRL-V to open
Similarly to {ctrlp.vim}{3}, use Enter key, CTRL-T, CTRL-X or CTRL-V to open
selected files in the current window, in new tabs, in horizontal splits, or in
vertical splits respectively.
@@ -218,7 +218,6 @@ list:
`fg` / `bg` / `hl` | Item (foreground / background / highlight)
`fg+` / `bg+` / `hl+` | Current item (foreground / background / highlight)
`preview-fg` / `preview-bg` | Preview window text and background
`hl` / `hl+` | Highlighted substrings (normal / current)
`gutter` | Background of the gutter on the left
`pointer` | Pointer to the current line ( `>` )
`marker` | Multi-select marker ( `>` )
@@ -229,7 +228,6 @@ list:
`query` | Query string
`disabled` | Query string when search is disabled
`prompt` | Prompt before query ( `> ` )
`pointer` | Pointer to the current line ( `>` )
----------------------------+------------------------------------------------------
- `component` specifies the component (`fg` / `bg`) from which to extract the
color when considering each of the following highlight groups
@@ -245,7 +243,7 @@ if it exists, - otherwise use the `fg` attribute of the `Comment` highlight
group if it exists, - otherwise fall back to the default color settings for
the prompt.
You can examine the color option generated according the setting by printing
You can examine the color option generated according to the setting by printing
the result of `fzf#wrap()` function like so:
>
:echo fzf#wrap()
@@ -503,7 +501,7 @@ LICENSE *fzf-license*
The MIT License (MIT)
Copyright (c) 2013-2025 Junegunn Choi
Copyright (c) 2013-2026 Junegunn Choi
==============================================================================
vim:tw=78:sw=2:ts=2:ft=help:norl:nowrap:
+1 -1
View File
@@ -4,7 +4,7 @@ require (
github.com/charlievieth/fastwalk v1.0.14
github.com/gdamore/tcell/v2 v2.9.0
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741
github.com/mattn/go-isatty v0.0.20
github.com/mattn/go-isatty v0.0.22
github.com/rivo/uniseg v0.4.7
golang.org/x/sys v0.35.0
golang.org/x/term v0.34.0
+2 -3
View File
@@ -8,8 +8,8 @@ github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 h1:7dYDtfMD
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741/go.mod h1:6EILKtGpo5t+KLb85LNZLAF6P9LKp78hJI80PXMcn3c=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -33,7 +33,6 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+103 -25
View File
@@ -2,11 +2,11 @@
set -u
version=0.66.1
version=0.73.1
auto_completion=
key_bindings=
update_config=2
shells="bash zsh fish"
shells="bash zsh fish nushell"
prefix='~/.fzf'
prefix_expand=~/.fzf
fish_dir=${XDG_CONFIG_HOME:-$HOME/.config}/fish
@@ -27,6 +27,7 @@ usage: $0 [OPTIONS]
--no-bash Do not set up bash configuration
--no-zsh Do not set up zsh configuration
--no-fish Do not set up fish configuration
--no-nushell Do not set up nushell configuration
EOF
}
@@ -56,6 +57,7 @@ for opt in "$@"; do
--no-bash) shells=${shells/bash/} ;;
--no-zsh) shells=${shells/zsh/} ;;
--no-fish) shells=${shells/fish/} ;;
--no-nushell) shells=${shells/nushell/} ;;
*)
echo "unknown option: $opt"
help
@@ -112,10 +114,15 @@ link_fzf_in_path() {
return 1
}
tar_opts="-xzf -"
if tar --no-same-owner -tf /dev/null 2> /dev/null; then
tar_opts="--no-same-owner $tar_opts"
fi
try_curl() {
command -v curl > /dev/null &&
if [[ $1 =~ tar.gz$ ]]; then
curl -fL $1 | tar --no-same-owner -xzf -
curl -fL $1 | tar $tar_opts
else
local temp=${TMPDIR:-/tmp}/fzf.zip
curl -fLo "$temp" $1 && unzip -o "$temp" && rm -f "$temp"
@@ -125,7 +132,7 @@ try_curl() {
try_wget() {
command -v wget > /dev/null &&
if [[ $1 =~ tar.gz$ ]]; then
wget -O - $1 | tar --no-same-owner -xzf -
wget -O - $1 | tar $tar_opts
else
local temp=${TMPDIR:-/tmp}/fzf.zip
wget -O "$temp" $1 && unzip -o "$temp" && rm -f "$temp"
@@ -177,6 +184,7 @@ case "$archi" in
Linux\ aarch64\ Android) download fzf-$version-android_arm64.tar.gz ;;
Linux\ aarch64*) download fzf-$version-linux_arm64.tar.gz ;;
Linux\ loongarch64*) download fzf-$version-linux_loong64.tar.gz ;;
Linux\ riscv64*) download fzf-$version-linux_riscv64.tar.gz ;;
Linux\ ppc64le*) download fzf-$version-linux_ppc64le.tar.gz ;;
Linux\ *64*) download fzf-$version-linux_amd64.tar.gz ;;
Linux\ s390x*) download fzf-$version-linux_s390x.tar.gz ;;
@@ -218,12 +226,14 @@ fi
[[ $* =~ "--bin" ]] && exit 0
for s in $shells; do
if ! command -v "$s" > /dev/null; then
bin=$s
[[ $s == nushell ]] && bin=nu
if ! command -v "$bin" > /dev/null; then
shells=${shells/$s/}
fi
done
if [[ ${#shells} -lt 3 ]]; then
if [[ -z ${shells// /} ]]; then
echo "No shell configuration to be updated."
exit 0
fi
@@ -242,16 +252,17 @@ fi
echo
for shell in $shells; do
[[ $shell == nushell ]] && continue
fzf_completion="source \"$fzf_base/shell/completion.${shell}\""
fzf_key_bindings="source \"$fzf_base/shell/key-bindings.${shell}\""
[[ $shell == fish ]] && continue
src=${prefix_expand}.${shell}
echo -n "Generate $src ... "
fzf_completion="source \"$fzf_base/shell/completion.${shell}\""
if [ $auto_completion -eq 0 ]; then
fzf_completion="# $fzf_completion"
fi
fzf_key_bindings="source \"$fzf_base/shell/key-bindings.${shell}\""
if [ $key_bindings -eq 0 ]; then
fzf_key_bindings="# $fzf_key_bindings"
fi
@@ -301,15 +312,16 @@ append_line() {
line="$2"
file="$3"
pat="${4:-}"
at_lno="${5:-}"
lines=""
echo "Update $file:"
echo " - $line"
if [ -f "$file" ]; then
if [ $# -lt 4 ]; then
lines=$(\grep -nF "$line" "$file")
else
if [[ -n $pat ]]; then
lines=$(\grep -nF "$pat" "$file")
else
lines=$(\grep -nF "${line#"${line%%[![:space:]]*}"}" "$file")
fi
fi
@@ -327,8 +339,12 @@ append_line() {
set -e
if [ "$update" -eq 1 ]; then
[ -f "$file" ] && echo >> "$file"
echo "$line" >> "$file"
if [[ -z $at_lno ]]; then
[ -f "$file" ] && echo >> "$file"
echo "$line" >> "$file"
else
sed -i.~fzf_bak "${at_lno}a\\"$'\n'"$line" "$file" && rm "$file.~fzf_bak"
fi
echo " + Added"
else
echo " ~ Skipped"
@@ -357,33 +373,94 @@ fi
echo
for shell in $shells; do
[[ $shell == fish ]] && continue
[[ $shell == nushell ]] && continue
[ $shell = zsh ] && dest=${ZDOTDIR:-~}/.zshrc || dest=~/.bashrc
append_line $update_config "[ -f ${prefix}.${shell} ] && source ${prefix}.${shell}" "$dest" "${prefix}.${shell}"
done
if [ $key_bindings -eq 1 ] && [[ $shells =~ fish ]]; then
if [[ $shells =~ fish ]]; then
bind_file="${fish_dir}/functions/fish_user_key_bindings.fish"
if [ ! -e "$bind_file" ]; then
mkdir -p "${fish_dir}/functions"
create_file "$bind_file" \
'function fish_user_key_bindings' \
' fzf --fish | source' \
'end'
if [[ $key_bindings -eq 1 || $auto_completion -eq 1 ]]; then
mkdir -p "${fish_dir}/functions"
if [[ $key_bindings -eq 1 && $auto_completion -eq 1 ]]; then
create_file "$bind_file" \
'function fish_user_key_bindings' \
' fzf --fish | source' \
'end'
elif [[ $key_bindings -eq 1 ]]; then
create_file "$bind_file" \
'function fish_user_key_bindings' \
" $fzf_key_bindings" \
'end'
elif [[ $auto_completion -eq 1 ]]; then
create_file "$bind_file" \
'function fish_user_key_bindings' \
" $fzf_completion" \
'end'
fi
lno_func=$(\grep -nF "function fish_user_key_bindings" "$bind_file" | sed 's/:.*//' | tr '\n' ' ')
else
lno_func=0
fi
else
echo "Check $bind_file:"
lno=$(\grep -nF "fzf_key_bindings" "$bind_file" | sed 's/:.*//' | tr '\n' ' ')
if [[ -n $lno ]]; then
echo " ** Found 'fzf_key_bindings' in line #$lno"
echo " ** You have to replace the line to 'fzf --fish | source'"
lno_func=$(\grep -nF "function fish_user_key_bindings" "$bind_file" | sed 's/:.*//' | tr '\n' ' ')
if [[ -z $lno_func ]]; then
echo -e "function fish_user_key_bindings\nend" >> "$bind_file"
lno_func=$(\grep -nF "function fish_user_key_bindings" "$bind_file" | sed 's/:.*//' | tr '\n' ' ')
fi
lno_keys=$(\grep -nF "fzf_key_bindings" "$bind_file" | sed 's/:.*//' | tr '\n' ' ')
if [[ -n $lno_keys ]]; then
echo " ** Found 'fzf_key_bindings' in line #$lno_keys"
if [[ $key_bindings -eq 1 && $auto_completion -eq 1 ]]; then
echo " ** You have to replace the line to 'fzf --fish | source'"
elif [[ $key_bindings -eq 1 ]]; then
echo " ** You have to replace the line to '$fzf_key_bindings'"
else
echo " ** You have to remove the line"
fi
echo
else
echo " - Clear"
echo
append_line $update_config "fzf --fish | source" "$bind_file"
if [[ $key_bindings -eq 1 && $auto_completion -eq 1 ]]; then
sed -i.~fzf_bak "\#$fzf_completion#d" "$bind_file" && rm "$bind_file.~fzf_bak"
sed -i.~fzf_bak "\#$fzf_key_bindings#d" "$bind_file" && rm "$bind_file.~fzf_bak"
append_line $update_config " fzf --fish | source" "$bind_file" "" "$lno_func"
else
sed -i.~fzf_bak '/fzf --fish \| source/d' "$bind_file" && rm "$bind_file.~fzf_bak"
if [[ $key_bindings -eq 1 ]]; then
sed -i.~fzf_bak "\#$fzf_completion#d" "$bind_file" && rm "$bind_file.~fzf_bak"
append_line $update_config " $fzf_key_bindings" "$bind_file" "" "$lno_func"
elif [[ $auto_completion -eq 1 ]]; then
sed -i.~fzf_bak "\#$fzf_key_bindings#d" "$bind_file" && rm "$bind_file.~fzf_bak"
append_line $update_config " $fzf_completion" "$bind_file" "" "$lno_func"
fi
fi
fi
fi
fi
if [[ $shells =~ nushell ]]; then
if [[ $key_bindings -eq 1 || $auto_completion -eq 1 ]]; then
echo "Setting up Nushell integration ..."
nushell_autoload_dir=$(nu -c '$nu.user-autoload-dirs | first')
mkdir -p "$nushell_autoload_dir"
dest="$nushell_autoload_dir/_fzf_integration.nu"
echo -n " Generate $dest ... "
if [[ $key_bindings -eq 1 && $auto_completion -eq 1 ]]; then
"$fzf_base"/bin/fzf --nushell > "$dest"
elif [[ $key_bindings -eq 1 ]]; then
cp "$fzf_base/shell/key-bindings.nu" "$dest"
else
cp "$fzf_base/shell/completion.nu" "$dest"
fi
echo "OK"
echo
fi
fi
if [ $update_config -eq 1 ]; then
echo 'Finished. Restart your shell or reload config file.'
if [[ $shells =~ bash ]]; then
@@ -392,7 +469,8 @@ if [ $update_config -eq 1 ]; then
echo
fi
[[ $shells =~ zsh ]] && echo " source ${ZDOTDIR:-~}/.zshrc # zsh"
[[ $shells =~ fish ]] && [ $key_bindings -eq 1 ] && echo ' fzf_key_bindings # fish'
[[ $shells =~ fish && $lno_func -ne 0 ]] && echo ' fish_user_key_bindings # fish'
[[ $shells =~ nushell ]] && echo ' # nushell: files are loaded automatically from autoload directory'
echo
echo 'Use uninstall script to remove fzf.'
echo
+1 -1
View File
@@ -1,4 +1,4 @@
$version="0.66.1"
$version="0.73.1"
$fzf_base=Split-Path -Parent $MyInvocation.MyCommand.Definition
+16 -2
View File
@@ -11,7 +11,7 @@ import (
"github.com/junegunn/fzf/src/protector"
)
var version = "0.66"
var version = "0.73"
var revision = "devel"
//go:embed shell/key-bindings.bash
@@ -29,6 +29,15 @@ var zshCompletion []byte
//go:embed shell/key-bindings.fish
var fishKeyBindings []byte
//go:embed shell/key-bindings.nu
var nushellKeyBindings []byte
//go:embed shell/completion.nu
var nushellCompletion []byte
//go:embed shell/completion.fish
var fishCompletion []byte
//go:embed man/man1/fzf.1
var manPage []byte
@@ -65,7 +74,12 @@ func main() {
}
if options.Fish {
printScript("key-bindings.fish", fishKeyBindings)
fmt.Println("fzf_key_bindings")
printScript("completion.fish", fishCompletion)
return
}
if options.Nushell {
printScript("key-bindings.nu", nushellKeyBindings)
printScript("completion.nu", nushellCompletion)
return
}
if options.Help {
+2 -2
View File
@@ -1,7 +1,7 @@
.ig
The MIT License (MIT)
Copyright (c) 2013-2025 Junegunn Choi
Copyright (c) 2013-2026 Junegunn Choi
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -21,7 +21,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
..
.TH fzf\-tmux 1 "Oct 2025" "fzf 0.66.1" "fzf\-tmux - open fzf in tmux split pane"
.TH fzf\-tmux 1 "May 2026" "fzf 0.73.1" "fzf\-tmux - open fzf in tmux split pane"
.SH NAME
fzf\-tmux - open fzf in tmux split pane
+195 -33
View File
@@ -1,7 +1,7 @@
.ig
The MIT License (MIT)
Copyright (c) 2013-2025 Junegunn Choi
Copyright (c) 2013-2026 Junegunn Choi
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -21,7 +21,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
..
.TH fzf 1 "Oct 2025" "fzf 0.66.1" "fzf - a command-line fuzzy finder"
.TH fzf 1 "May 2026" "fzf 0.73.1" "fzf - a command-line fuzzy finder"
.SH NAME
fzf - a command-line fuzzy finder
@@ -134,6 +134,14 @@ e.g.
# Use template to rearrange fields
echo foo,bar,baz | fzf --delimiter , --with-nth '{n},{1},{3},{2},{1..2}'
.RE
.RS
\fBchange\-with\-nth\fR action is only available when \fB\-\-with\-nth\fR is set.
When \fB\-\-with\-nth\fR is used, fzf retains the original input lines in memory
so they can be re\-transformed on the fly (e.g. \fB\-\-with\-nth ..\fR to keep
the original presentation). This increases memory usage, so only use
\fB\-\-with\-nth\fR when you actually need field transformation.
.RE
.TP
.BI "\-\-accept\-nth=" "N[,..] or TEMPLATE"
Define which fields to print on accept. The last delimiter is stripped from the
@@ -272,6 +280,7 @@ color mappings. Each entry is separated by a comma and/or whitespaces.
\fBgutter \fRGutter on the left
\fBcurrent\-hl (hl+) \fRHighlighted substrings (current line)
\fBalt\-bg \fRAlternate background color to create striped lines
\fBalt\-gutter \fRAlternate gutter color to create the striped pattern
\fBquery (input\-fg) \fRQuery string
\fBghost \fRGhost text (\fB\-\-ghost\fR, \fBdim\fR applied by default)
\fBdisabled \fRQuery string when search is disabled (\fB\-\-disabled\fR)
@@ -325,10 +334,14 @@ color mappings. Each entry is separated by a comma and/or whitespaces.
\fB#rrggbb \fR24-bit colors
.B ANSI ATTRIBUTES: (Only applies to foreground colors)
\fBregular \fRClear previously set attributes; should precede the other ones
\fBstrip \fRRemove colors
\fBregular \fRClear previously set attributes; should precede the other ones
\fBstrip \fRRemove colors
\fBbold\fR
\fBunderline\fR
\fBunderline-double\fR
\fBunderline-curly\fR
\fBunderline-dotted\fR
\fBunderline-dashed\fR
\fBreverse\fR
\fBdim\fR
\fBitalic\fR
@@ -371,7 +384,7 @@ Use black background
.SS DISPLAY MODE
.TP
.BI "\-\-height=" "[~]HEIGHT[%]"
.BI "\-\-height=" "[~][\-]HEIGHT[%]"
Display fzf window below the cursor with the given height instead of using
the full screen.
@@ -381,17 +394,19 @@ height minus the given value.
fzf \-\-height=\-1
When prefixed with \fB~\fR, fzf will automatically determine the height in the
range according to the input size.
range according to the input size. You can combine \fB~\fR with a negative
value.
# Will not take up 100% of the screen
seq 5 | fzf \-\-height=~100%
# Adapt to input size, up to terminal height minus 1
seq 5 | fzf \-\-height=~\-1
Adaptive height has the following limitations:
.br
* Cannot be used with top/bottom margin and padding given in percent size
.br
* Negative value is not allowed
.br
* It will not find the right size when there are multi-line items
.TP
@@ -402,25 +417,26 @@ layout options so that the specified number of items are visible in the list
section (default: \fB10+\fR).
Ignored when \fB\-\-height\fR is not specified or set as an absolute value.
.TP
.BI "\-\-tmux" "[=[center|top|bottom|left|right][,SIZE[%]][,SIZE[%]][,border-native]]"
Start fzf in a tmux popup (default \fBcenter,50%\fR). Requires tmux 3.3 or
later. This option is ignored if you are not running fzf inside tmux.
.BI "\-\-popup" "[=[center|top|bottom|left|right][,SIZE[%]][,SIZE[%]][,border-native]]"
Start fzf in a tmux popup or in a Zellij floating pane (default
\fBcenter,50%\fR). Requires tmux 3.3+ or Zellij 0.44+. This option is ignored if you
are not running fzf inside tmux or Zellij. \fB\-\-tmux\fR is an alias for this option.
e.g.
\fB# Popup in the center with 70% width and height
fzf \-\-tmux 70%
fzf \-\-popup 70%
# Popup on the left with 40% width and 100% height
fzf \-\-tmux right,40%
fzf \-\-popup right,40%
# Popup on the bottom with 100% width and 30% height
fzf \-\-tmux bottom,30%
fzf \-\-popup bottom,30%
# Popup on the top with 80% width and 40% height
fzf \-\-tmux top,80%,40%
fzf \-\-popup top,80%,40%
# Popup with a native tmux border in the center with 80% width and height
fzf \-\-tmux center,80%,border\-native\fR
# Popup with a native tmux or Zellij border in the center with 80% width and height
fzf \-\-popup center,80%,border\-native\fR
.SS LAYOUT
.TP
@@ -501,6 +517,8 @@ Draw border around the finder
.br
.BR double " Border with double lines"
.br
.BR dashed " Border with dashed lines and rounded corners"
.br
.BR block " Border using block elements; suitable when using different background colors"
.br
.BR thinblock " Border using legacy computing symbols; may not be displayed on some terminals"
@@ -588,8 +606,11 @@ Highlight the whole current line
.B "\-\-cycle"
Enable cyclic scroll
.TP
.B "\-\-wrap"
Enable line wrap
.BI "\-\-wrap" "[=MODE]"
Enable line wrap. \fIMODE\fR can be \fBchar\fR (default) or \fBword\fR.
\fBword\fR mode wraps lines at word boundaries (spaces and tabs) instead of
at arbitrary character positions. \fB\-\-wrap\-word\fR is a synonym for
\fB\-\-wrap=word\fR.
.TP
.BI "\-\-wrap\-sign" "=INDICATOR"
Indicator for wrapped lines. The default is '↳ ' or '> ' depending on
@@ -601,17 +622,53 @@ Disable multi-line display of items when using \fB\-\-read0\fR
.B "\-\-raw"
Enable raw mode where non-matching items are also displayed in a dimmed color.
.TP
.B "\-\-track"
.BI "\-\-track"
Make fzf track the current selection when the result list is updated.
This can be useful when browsing logs using fzf with sorting disabled. It is
not recommended to use this option with \fB\-\-tac\fR as the resulting behavior
can be confusing. Also, consider using \fBtrack\fR action instead of this
option.
can be confusing.
When \fB\-\-id\-nth\fR is also set, fzf enables field\-based tracking across
\fBreload\fRs. See \fB\-\-id\-nth\fR for details.
Without \fB\-\-id\-nth\fR, \fB\-\-track\fR uses index\-based tracking that
does not persist across reloads.
.RS
e.g.
\fBgit log \-\-oneline \-\-graph \-\-color=always | nl |
\fB# Index\-based tracking (does not persist across reloads)
git log \-\-oneline \-\-graph \-\-color=always | nl |
fzf \-\-ansi \-\-track \-\-no\-sort \-\-layout=reverse\-list\fR
\fB# Track by first field (e.g. pod name) across reloads
kubectl get pods | fzf \-\-track \-\-id\-nth 1 \-\-header\-lines=1 \\
\-\-bind 'ctrl\-r:reload:kubectl get pods'\fR
.RE
.TP
.BI "\-\-id\-nth=" "N[,..]"
Define item identity fields for cross\-reload operations. When set, fzf
uses the specified fields to identify items across \fBreload\fR and
\fBreload\-sync\fR.
With \fB\-\-track\fR, fzf extracts the tracking key from the current item
using the nth expression and searches for a matching item in the reloaded list.
While searching, the UI is blocked (query input and cursor movement are
disabled, and the prompt is dimmed). With \fBreload\fR, the blocked state
clears as soon as the match is found in the stream. With \fBreload\-sync\fR,
the blocked state persists until the entire stream is complete. Press
\fBEscape\fR or \fBCtrl\-C\fR to cancel the blocked state without quitting fzf.
The info line shows \fB+T*\fR (or \fB+t*\fR for one\-off tracking) while
the search is in progress.
With \fB\-\-multi\fR, selected items are preserved across \fBreload\-sync\fR
by matching their identity fields in the reloaded list.
.RS
e.g.
\fB# Track and preserve selections by pod name across reloads
kubectl get pods | fzf \-\-multi \-\-track \-\-id\-nth 1 \-\-header\-lines=1 \\
\-\-bind 'ctrl\-r:reload\-sync:kubectl get pods'\fR
.RE
.TP
.B "\-\-tac"
@@ -629,9 +686,16 @@ Render empty lines between each item
The given string will be repeated to draw a horizontal line on each gap
(default: '┈' or '\-' depending on \fB\-\-no\-unicode\fR).
.TP
.BI "\-\-freeze\-left=" "N"
Number of fields to freeze on the left.
.TP
.BI "\-\-freeze\-right=" "N"
Number of fields to freeze on the right.
.TP
.B "\-\-keep\-right"
Keep the right end of the line visible when it's too long. Effective only when
the query string is empty.
the query string is empty. Use \fB\-\-freeze\-right=1\fR instead if you want
the last field to be always visible even with a non-empty query.
.TP
.BI "\-\-scroll\-off=" "LINES"
Number of screen lines to keep above or below when scrolling to the top or to
@@ -651,6 +715,9 @@ Label characters for \fBjump\fR mode.
.BI "\-\-gutter=" "CHAR"
Character used for the gutter column (default: '▌' unless \fB\-\-no\-unicode\fR is given)
.TP
.BI "\-\-gutter\-raw=" "CHAR"
Character used for the gutter column in raw mode (default: '▖' unless \fB\-\-no\-unicode\fR is given)
.TP
.BI "\-\-pointer=" "STR"
Pointer to the current line (default: '▌' or '>' depending on \fB\-\-no\-unicode\fR)
.TP
@@ -730,7 +797,7 @@ ENVIRONMENT VARIABLES EXPORTED TO CHILD PROCESSES.
e.g.
\fB# Prepend the current cursor position in yellow
fzf \-\-info\-command='echo \-e "\\x1b[33;1m$FZF_POS\\x1b[m/$FZF_INFO 💛"'\fR
fzf \-\-info\-command='printf "\\x1b[33;1m$FZF_POS\\x1b[m/$FZF_INFO 💛"'\fR
.TP
.B "\-\-no\-info"
@@ -830,6 +897,9 @@ e.g.
# This won't work properly without 'f' flag due to ARG_MAX limit.
seq 100000 | fzf \-\-preview "awk '{sum+=\\$1} END {print sum}' {*f}"\fR
\fB# Use {+f} to get the selected items as a line-separated list
seq 100 | fzf \-\-multi \-\-bind 'enter:become:cat {+f}'\fR
Also,
* \fB{q}\fR is replaced to the current query string
@@ -887,6 +957,8 @@ Should be used with one of the following \fB\-\-preview\-window\fR options.
.br
.B * border\-double
.br
.B * border\-dashed
.br
.B * border\-block
.br
.B * border\-thinblock
@@ -898,6 +970,11 @@ Should be used with one of the following \fB\-\-preview\-window\fR options.
.B * border\-bottom
.br
.TP
.BI "\-\-preview\-wrap\-sign" =INDICATOR
Indicator for wrapped lines in the preview window. If not set, the value of
\fB\-\-wrap\-sign\fR is used.
.TP
.BI "\-\-preview\-label\-pos" [=N[:top|bottom]]
Position of the border label on the border line of the preview window. Specify
@@ -908,7 +985,7 @@ default value 0 (or \fBcenter\fR) will put the label at the center of the
border line.
.TP
.BI "\-\-preview\-window=" "[POSITION][,SIZE[%]][,border\-STYLE][,[no]wrap][,[no]follow][,[no]cycle][,[no]info][,[no]hidden][,+SCROLL[OFFSETS][/DENOM]][,~HEADER_LINES][,default][,<SIZE_THRESHOLD(ALTERNATIVE_LAYOUT)]"
.BI "\-\-preview\-window=" "[POSITION][,SIZE[%]][,border\-STYLE][,[no]wrap][,wrap\-word][,[no]follow][,[no]cycle][,[no]info][,[no]hidden][,+SCROLL[OFFSETS][/DENOM]][,~HEADER_LINES][,default][,<SIZE_THRESHOLD(ALTERNATIVE_LAYOUT)]"
.RS
.B POSITION: (default: right)
@@ -916,9 +993,14 @@ border line.
\fBdown
\fBleft
\fBright
\fBnext
\fRDetermines the layout of the preview window.
* \fBnext\fR places the preview window adjacent to the input section, on
the list side: above the input in the default layout, below the input
in \fB\-\-layout=reverse\fR.
* If the argument contains \fB:hidden\fR, the preview window will be hidden by
default until \fBtoggle\-preview\fR action is triggered.
@@ -926,7 +1008,8 @@ default until \fBtoggle\-preview\fR action is triggered.
execute the command in the background.
* Long lines are truncated by default. Line wrap can be enabled with
\fBwrap\fR flag.
\fBwrap\fR flag. \fBwrap\-word\fR flag enables word-level wrapping, which
breaks lines at word boundaries instead of mid-word.
* Preview window will automatically scroll to the bottom when \fBfollow\fR
flag is set, similarly to how \fBtail \-f\fR works.
@@ -1026,7 +1109,17 @@ Print header before the prompt line. When both normal header and header lines
.TP
.BI "\-\-header\-border" [=STYLE]
Draw border around the header section. \fBline\fR style draws a single
separator line between the header window and the list section.
separator line between the header window and the list section. \fBinline\fR
style embeds the header inside the list border frame, joined to the list
section by a horizontal separator; it requires a \fB\-\-list\-border\fR
shape that has both top and bottom segments (rounded / sharp / bold /
double / dashed / block / thinblock / horizontal) and falls back to \fBline\fR
otherwise. When the list border also has side segments, the separator
joins them with T-junctions; \fBhorizontal\fR has no side borders, so the
separator is drawn without T-junction endpoints. Takes precedence over
\fB\-\-header\-first\fR (the section stays inside the list frame), and
when \fB\-\-header\-lines\fR is also set \fB\-\-header\-lines\-border\fR
must also be \fBinline\fR.
.TP
.BI "\-\-header\-label" [=LABEL]
@@ -1042,6 +1135,10 @@ Display header from \fB--header\-lines\fR with a separate border. Pass
\fBnone\fR to still separate the header lines but without a border. To combine
two headers, use \fB\-\-no\-header\-lines\-border\fR. \fBline\fR style draws
a single separator line between the header lines and the list section.
\fBinline\fR style embeds the header lines inside the list border frame
with a horizontal separator; it requires a \fB\-\-list\-border\fR shape
that has both top and bottom segments, falls back to \fBline\fR
otherwise.
.SS FOOTER
@@ -1055,7 +1152,10 @@ are not affected by \fB\-\-with\-nth\fR. ANSI color codes are processed even whe
.TP
.BI "\-\-footer\-border" [=STYLE]
Draw border around the footer section. \fBline\fR style draws a single
separator line between the footer and the list section.
separator line between the footer and the list section. \fBinline\fR style
embeds the footer inside the list border frame with a horizontal separator;
it requires a \fB\-\-list\-border\fR shape that has both top and bottom
segments and falls back to \fBline\fR otherwise.
.TP
.BI "\-\-footer\-label" [=LABEL]
@@ -1202,6 +1302,18 @@ Here is an example script that uses a Unix socket instead of a TCP port.
curl --unix-socket /tmp/fzf.sock http -d up
\fR
.TP
.BI "\-\-threads=" "N"
Number of matcher threads to use. The default value is
\fBmin(8 * NUM_CPU, 32)\fR.
.TP
.BI "\-\-bench=" "DURATION"
Repeatedly run \fB\-\-filter\fR for the given duration and print timing
statistics. Must be used with \fB\-\-filter\fR.
e.g.
\fBcat /usr/share/dict/words | fzf \-\-filter abc \-\-bench 10s\fR
.SS DIRECTORY TRAVERSAL
.TP
.B "\-\-walker=[file][,dir][,follow][,hidden]"
@@ -1261,6 +1373,12 @@ Print script to set up Fish shell integration
e.g. \fBfzf \-\-fish | source\fR
.TP
.B "\-\-nushell"
Print script to set up Nushell shell integration
e.g. \fBfzf \-\-nushell | save \-f ~/.config/nushell/autoload/_fzf_integration.nu\fR
.SS OTHERS
.TP
.B "\-\-no\-mouse"
@@ -1363,12 +1481,18 @@ fzf exports the following environment variables to its child processes.
.br
.BR FZF_POS " Vertical position of the cursor in the list starting from 1"
.br
.BR FZF_CURRENT_ITEM " Text of the current item (unset if the list is empty)"
.br
.BR FZF_WRAP " The line wrapping mode (char, word) when enabled"
.br
.BR FZF_QUERY " Current query string"
.br
.BR FZF_INPUT_STATE " Current input state (enabled, disabled, hidden)"
.br
.BR FZF_NTH " Current \-\-nth option"
.br
.BR FZF_WITH_NTH " Current \-\-with\-nth option"
.br
.BR FZF_PROMPT " Prompt string"
.br
.BR FZF_GHOST " Ghost string"
@@ -1389,6 +1513,10 @@ fzf exports the following environment variables to its child processes.
.br
.BR FZF_KEY " The name of the last key pressed"
.br
.BR FZF_IDLE_TIME " Whole seconds since the last user activity"
.br
.BR FZF_IDLE_TIME_MS " Milliseconds since the last user activity"
.br
.BR FZF_PORT " Port number when \-\-listen option is used"
.br
.BR FZF_SOCK " Unix socket path when \-\-listen option is used"
@@ -1403,6 +1531,10 @@ fzf exports the following environment variables to its child processes.
.br
.BR FZF_RAW " Only in raw mode. 1 if the current item matches, 0 otherwise"
.PP
.B FZF_CURRENT_ITEM
is omitted when the item contains a NUL byte, because exec(2) cannot pass it.
.SH EXTENDED SEARCH MODE
Unless specified otherwise, fzf will start in "extended\-search mode". In this
@@ -1485,7 +1617,7 @@ e.g.
.br
\fIctrl\-/\fR (\fIctrl\-_\fR)
.br
\fIctrl\-alt\-[a\-z]\fR
\fIctrl\-alt\-[a\-z]\fR (\fIctrl\-alt\-h\fR is \fIctrl\-alt\-backspace\fR on non-Windows)
.br
\fIalt\-[*]\fR (Any case-sensitive single character is allowed)
.br
@@ -1615,7 +1747,7 @@ e.g.
.br
\fIctrl\-alt\-end\fR
.br
\fIctrl\-alt\-backspace\fR (\fIctrl\-alt\-bspace\fR \fIctrl\-alt\-bs\fR)
\fIctrl\-alt\-backspace\fR (\fIctrl\-alt\-bspace\fR \fIctrl\-alt\-bs\fR) (\fIctrl\-alt\-h\fR (non-Windows))
.br
\fIctrl\-alt\-delete\fR
.br
@@ -1828,6 +1960,30 @@ variables starting from 1. It optionally sets \fBFZF_CLICK_FOOTER_WORD\fR
if clicked on a word.
.RE
\fIevery(N)\fR
.RS
Triggered every \fIN\fR seconds (\fIN\fR can be a fractional number, e.g.
\fB0.5\fR). The minimum interval is \fB0.01\fR seconds; values are floored
to that.
Combine with the \fBFZF_IDLE_TIME\fR (whole seconds) and
\fBFZF_IDLE_TIME_MS\fR (milliseconds) environment variables to build
idle\-based behavior without a separate event.
e.g.
\fB# Live process list, refreshed every 2 seconds.
# --track --id-nth 2 keeps the cursor on the same PID across reloads.
fzf \-\-header\-lines 1 \-\-track \-\-id\-nth 2 \\
\-\-bind 'start,every(2):reload\-sync:ps \-ef'
# Auto\-accept after 10 seconds of inactivity, with a countdown in the footer after 5s.
fzf \-\-bind 'every(1):bg\-transform:
if [[ $FZF_IDLE_TIME \-lt 5 ]]; then echo change\-footer:
elif [[ $FZF_IDLE_TIME \-lt 10 ]]; then echo "change\-footer:auto\-accept in $((10 \- FZF_IDLE_TIME))s"
else echo accept
fi'\fR
.RE
.SS AVAILABLE ACTIONS:
A key or an event can be bound to one or more of the following actions.
@@ -1852,12 +2008,14 @@ A key or an event can be bound to one or more of the following actions.
\fBchange\-border\-label(...)\fR (change \fB\-\-border\-label\fR to the given string)
\fBchange\-ghost(...)\fR (change ghost text to the given string)
\fBchange\-header(...)\fR (change header to the given string; doesn't affect \fB\-\-header\-lines\fR)
\fBchange\-header\-lines(N)\fR (change the number of \fB\-\-header\-lines\fR)
\fBchange\-header\-label(...)\fR (change \fB\-\-header\-label\fR to the given string)
\fBchange\-input\-label(...)\fR (change \fB\-\-input\-label\fR to the given string)
\fBchange\-list\-label(...)\fR (change \fB\-\-list\-label\fR to the given string)
\fBchange\-multi\fR (enable multi-select mode with no limit)
\fBchange\-multi(...)\fR (enable multi-select mode with a limit or disable it with 0)
\fBchange\-nth(...)\fR (change \fB\-\-nth\fR option; rotate through the multiple options separated by '|')
\fBchange\-with\-nth(...)\fR (change \fB\-\-with\-nth\fR option; rotate through the multiple options separated by '|')
\fBchange\-pointer(...)\fR (change \fB\-\-pointer\fR option)
\fBchange\-preview(...)\fR (change \fB\-\-preview\fR option)
\fBchange\-preview\-label(...)\fR (change \fB\-\-preview\-label\fR to the given string)
@@ -1943,12 +2101,14 @@ A key or an event can be bound to one or more of the following actions.
\fBtoggle\-multi\-line\fR
\fBtoggle\-preview\fR
\fBtoggle\-preview\-wrap\fR
\fBtoggle\-preview\-wrap\-word\fR
\fBtoggle\-raw\fR (toggle raw mode for displaying non-matching items)
\fBtoggle\-search\fR (toggle search functionality)
\fBtoggle\-sort\fR
\fBtoggle\-track\fR (toggle global tracking option (\fB\-\-track\fR))
\fBtoggle\-track\-current\fR (toggle tracking of the current item)
\fBtoggle\-wrap\fR \fIctrl\-/\fR \fIalt\-/\fR
\fBtoggle\-wrap\fR
\fBtoggle\-wrap\-word\fR \fIctrl\-/\fR \fIalt\-/\fR
\fBtoggle+down\fR \fIctrl\-i (tab)\fR
\fBtoggle+up\fR \fIbtab (shift\-tab)\fR
\fBtrack\-current\fR (track the current item; automatically disabled if focus changes)
@@ -1956,10 +2116,12 @@ A key or an event can be bound to one or more of the following actions.
\fBtransform\-border\-label(...)\fR (transform border label using an external command)
\fBtransform\-ghost(...)\fR (transform ghost text using an external command)
\fBtransform\-header(...)\fR (transform header using an external command)
\fBtransform\-header\-lines(...)\fR (transform the number of \fB\-\-header\-lines\fR using an external command)
\fBtransform\-header\-label(...)\fR (transform header label using an external command)
\fBtransform\-input\-label(...)\fR (transform input label using an external command)
\fBtransform\-list\-label(...)\fR (transform list label using an external command)
\fBtransform\-nth(...)\fR (transform nth using an external command)
\fBtransform\-with\-nth(...)\fR (transform with-nth using an external command)
\fBtransform\-pointer(...)\fR (transform pointer using an external command)
\fBtransform\-preview\-label(...)\fR (transform preview label using an external command)
\fBtransform\-prompt(...)\fR (transform prompt string using an external command)
@@ -2095,7 +2257,7 @@ payload of HTTP POST request to the \fB\-\-listen\fR server.
e.g.
\fB# Disallow selecting an empty line
echo \-e "1. Hello\\n2. Goodbye\\n\\n3. Exit" |
printf "1. Hello\\n2. Goodbye\\n\\n3. Exit" |
fzf \-\-height '~100%' \-\-reverse \-\-header 'Select one' \\
\-\-bind 'enter:transform:[[ \-n {} ]] &&
echo accept ||
+58 -26
View File
@@ -1,4 +1,4 @@
" Copyright (c) 2013-2025 Junegunn Choi
" Copyright (c) 2013-2026 Junegunn Choi
"
" MIT License
"
@@ -896,6 +896,7 @@ function! s:execute_term(dict, command, temps) abort
endif
endfunction
function! fzf.on_exit(id, code, ...)
silent! autocmd! fzf_popup_resize
if s:getpos() == self.ppos " {'window': 'enew'}
for [opt, val] in items(self.winopts)
execute 'let' opt '=' val
@@ -1023,15 +1024,17 @@ function! s:callback(dict, lines) abort
endfunction
if has('nvim')
function s:create_popup(opts) abort
function! s:create_popup() abort
let opts = s:popup_bounds()
let opts = extend({'relative': 'editor', 'style': 'minimal'}, opts)
let buf = nvim_create_buf(v:false, v:true)
let opts = extend({'relative': 'editor', 'style': 'minimal'}, a:opts)
let win = nvim_open_win(buf, v:true, opts)
call setwinvar(win, '&colorcolumn', '')
let s:popup_id = nvim_open_win(buf, v:true, opts)
call setwinvar(s:popup_id, '&colorcolumn', '')
" Colors
try
call setwinvar(win, '&winhighlight', 'Pmenu:,Normal:Normal')
call setwinvar(s:popup_id, '&winhighlight', 'Pmenu:,Normal:Normal')
let rules = get(g:, 'fzf_colors', {})
if has_key(rules, 'bg')
let color = call('s:get_color', rules.bg)
@@ -1039,40 +1042,61 @@ if has('nvim')
let ns = nvim_create_namespace('fzf_popup')
let hl = nvim_set_hl(ns, 'Normal',
\ &termguicolors ? { 'bg': color } : { 'ctermbg': str2nr(color) })
call nvim_win_set_hl_ns(win, ns)
call nvim_win_set_hl_ns(s:popup_id, ns)
endif
endif
catch
endtry
return buf
endfunction
function! s:resize_popup() abort
if !exists('s:popup_id') || !nvim_win_is_valid(s:popup_id)
return
endif
let opts = s:popup_bounds()
let opts = extend({'relative': 'editor'}, opts)
call nvim_win_set_config(s:popup_id, opts)
endfunction
else
function! s:create_popup(opts) abort
let s:popup_create = {buf -> popup_create(buf, #{
\ line: a:opts.row,
\ col: a:opts.col,
\ minwidth: a:opts.width,
\ maxwidth: a:opts.width,
\ minheight: a:opts.height,
\ maxheight: a:opts.height,
\ zindex: 1000,
\ })}
function! s:create_popup() abort
function! s:popup_create(buf)
let s:popup_id = popup_create(a:buf, #{zindex: 1000})
call s:resize_popup()
endfunction
autocmd TerminalOpen * ++once call s:popup_create(str2nr(expand('<abuf>')))
endfunction
function! s:resize_popup() abort
if !exists('s:popup_id') || empty(popup_getpos(s:popup_id))
return
endif
let opts = s:popup_bounds()
call popup_move(s:popup_id, {
\ 'line': opts.row,
\ 'col': opts.col,
\ 'minwidth': opts.width,
\ 'maxwidth': opts.width,
\ 'minheight': opts.height,
\ 'maxheight': opts.height,
\ })
endfunction
endif
function! s:popup(opts) abort
let xoffset = get(a:opts, 'xoffset', 0.5)
let yoffset = get(a:opts, 'yoffset', 0.5)
let relative = get(a:opts, 'relative', 0)
function! s:popup_bounds() abort
let opts = s:popup_opts
let xoffset = get(opts, 'xoffset', 0.5)
let yoffset = get(opts, 'yoffset', 0.5)
let relative = get(opts, 'relative', 0)
" Use current window size for positioning relatively positioned popups
let columns = relative ? winwidth(0) : &columns
let lines = relative ? winheight(0) : (&lines - has('nvim'))
" Size and position
let width = min([max([8, a:opts.width > 1 ? a:opts.width : float2nr(columns * a:opts.width)]), columns])
let height = min([max([4, a:opts.height > 1 ? a:opts.height : float2nr(lines * a:opts.height)]), lines])
let width = min([max([8, opts.width > 1 ? opts.width : float2nr(columns * opts.width)]), columns])
let height = min([max([4, opts.height > 1 ? opts.height : float2nr(lines * opts.height)]), lines])
let row = float2nr(yoffset * (lines - height)) + (relative ? win_screenpos(0)[0] - 1 : 0)
let col = float2nr(xoffset * (columns - width)) + (relative ? win_screenpos(0)[1] - 1 : 0)
@@ -1082,9 +1106,17 @@ function! s:popup(opts) abort
let row += !has('nvim')
let col += !has('nvim')
call s:create_popup({
\ 'row': row, 'col': col, 'width': width, 'height': height
\ })
return { 'row': row, 'col': col, 'width': width, 'height': height }
endfunction
function! s:popup(opts) abort
let s:popup_opts = a:opts
call s:create_popup()
augroup fzf_popup_resize
autocmd!
autocmd VimResized * call s:resize_popup()
augroup END
endfunction
let s:default_action = {
+6 -3
View File
@@ -1,9 +1,9 @@
__fzf_defaults() {
# $1: Prepend to FZF_DEFAULT_OPTS_FILE and FZF_DEFAULT_OPTS
# $2: Append to FZF_DEFAULT_OPTS_FILE and FZF_DEFAULT_OPTS
printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
builtin printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
command cat "${FZF_DEFAULT_OPTS_FILE-}" 2> /dev/null
printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
builtin printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
}
__fzf_exec_awk() {
@@ -26,7 +26,10 @@ __fzf_exec_awk() {
# version >= 1.3.4
local n x y z d
IFS=' .' read -r n x y z d <<< $(command mawk -W version 2> /dev/null)
[[ $n == mawk ]] && ((d >= 20230302 && (x * 1000 + y) * 1000 + z >= 1003004)) && __fzf_awk=mawk
[[ $n == mawk ]] &&
(((x * 1000 + y) * 1000 + z >= 1003004)) 2> /dev/null &&
((d >= 20230302)) 2> /dev/null &&
__fzf_awk=mawk
fi
fi
# Note: macOS awk has a quirk that it stops processing at all when it sees
+91
View File
@@ -0,0 +1,91 @@
# ____ ____
# / __/___ / __/
# / /_/_ / / /_
# / __/ / /_/ __/
# /_/ /___/_/ completion-examples.nu
#
# Example custom completers for fzf's Nushell integration.
#
# To use these, add the desired entries to $env.FZF_COMPLETERS in your
# config.nu. Each closure receives two arguments:
# - prefix: the text before the trigger (e.g. "vim" in "vim **<TAB>")
# - spans: the full command as a list of words (e.g. ["pacman", "-S", "vim**"])
#
# A closure can return either:
# - a list of candidate strings (fzf will use default options), or
# - a record with the following optional fields:
# candidates: list<string> # candidates to feed to fzf
# opts: list<string> # custom fzf options (default: ["-m"])
# post: closure (|sel| ...) # post-processing of the selected item
#
# Simple example:
# $env.FZF_COMPLETERS = {
# git: {|prefix, spans| ["branch-main", "branch-dev", "branch-feature"]}
# }
# --- pacman / paru ---
# Completes package names for pacman and paru.
# Uses the spans to distinguish between subcommands:
# -S (sync), -F (files): list available packages from repos
# -Q (query), -R (remove): list installed packages
# Returns a record with custom fzf options for package preview.
$env.FZF_COMPLETERS = {}
$env.FZF_COMPLETERS.pacman = {|prefix, spans|
let sub = $spans | skip 1 | first
let candidates = (if ($sub =~ "-[SF]") {
^pacman -Slq | lines
} else if ($sub =~ "-[QR]") {
^pacman -Qq | lines
} else {
[]
})
{
candidates: $candidates
opts: ["-m", "--preview", "pacman -Si {}", "--prompt", "Package > "]
}
}
$env.FZF_COMPLETERS.paru = {|prefix, spans|
let sub = $spans | skip 1 | first
let candidates = (if ($sub =~ "-[SF]") {
^pacman -Slq | lines
} else if ($sub =~ "-[QR]") {
^pacman -Qq | lines
} else {
[]
})
{
candidates: $candidates
opts: ["-m", "--preview", "pacman -Si {}", "--prompt", "Package > "]
}
}
# --- pass (password-store) ---
# Completes entry names from ~/.password-store.
# Returns a simple list (no custom fzf options needed).
$env.FZF_COMPLETERS.pass = {|prefix, spans|
try {
ls ~/.password-store/**/*.gpg
| get name
| each {$in | str replace -r '^.*?\.password-store/(.*).gpg' '${1}'}
} catch {
[]
}
}
# --- Example with post-processing hook ---
# The "post" closure transforms the selected line after fzf returns.
# This is useful when the displayed line contains more information than
# what you want inserted on the command line (e.g. extracting a PID from
# a full "ps" output line).
#
# $env.FZF_COMPLETERS.mycommand = {|prefix, spans|
# {
# candidates: (^some-command | lines)
# opts: ["+m", "--header-lines=1"]
# post: {|selection| $selection | split row ' ' | get 0}
# }
# }
+75 -25
View File
@@ -4,8 +4,6 @@
# / __/ / /_/ __/
# /_/ /___/_/ completion.bash
#
# - $FZF_TMUX (default: 0)
# - $FZF_TMUX_OPTS (default: empty)
# - $FZF_COMPLETION_TRIGGER (default: '**')
# - $FZF_COMPLETION_OPTS (default: empty)
# - $FZF_COMPLETION_PATH_OPTS (default: empty)
@@ -38,9 +36,9 @@ if [[ $- =~ i ]]; then
# the changes. See code comments in "common.sh" for the implementation details.
__fzf_defaults() {
printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
builtin printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
command cat "${FZF_DEFAULT_OPTS_FILE-}" 2> /dev/null
printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
builtin printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
}
__fzf_exec_awk() {
@@ -51,7 +49,10 @@ __fzf_exec_awk() {
elif command -v mawk > /dev/null 2>&1; then
local n x y z d
IFS=' .' read -r n x y z d <<< $(command mawk -W version 2> /dev/null)
[[ $n == mawk ]] && ((d >= 20230302 && (x * 1000 + y) * 1000 + z >= 1003004)) && __fzf_awk=mawk
[[ $n == mawk ]] &&
(((x * 1000 + y) * 1000 + z >= 1003004)) 2> /dev/null &&
((d >= 20230302)) 2> /dev/null &&
__fzf_awk=mawk
fi
fi
LC_ALL=C exec "$__fzf_awk" "$@"
@@ -78,7 +79,7 @@ __fzf_orig_completion() {
f="${BASH_REMATCH[2]}"
cmd="${BASH_REMATCH[3]}"
[[ $f == _fzf_* ]] && continue
printf -v "_fzf_orig_completion_${cmd//[^A-Za-z0-9_]/_}" "%s" "${comp} %s ${cmd} #${f}"
builtin printf -v "_fzf_orig_completion_${cmd//[^A-Za-z0-9_]/_}" "%s" "${comp} %s ${cmd} #${f}"
if [[ $l == *" -o nospace "* ]] && [[ ${__fzf_nospace_commands-} != *" $cmd "* ]]; then
__fzf_nospace_commands="${__fzf_nospace_commands-} $cmd "
fi
@@ -108,7 +109,7 @@ __fzf_orig_completion_instantiate() {
orig="${!orig_var-}"
orig="${orig%#*}"
[[ $orig == *' %s '* ]] || return 1
printf -v REPLY "$orig" "$func"
builtin printf -v REPLY "$orig" "$func"
}
_fzf_opts_completion() {
@@ -121,6 +122,7 @@ _fzf_opts_completion() {
+i --no-ignore-case
+s --no-sort
+x --no-extended
--accept-nth
--ansi
--bash
--bind
@@ -134,56 +136,89 @@ _fzf_opts_completion() {
--expect
--filepath-word
--fish
--footer
--footer-border
--footer-label
--footer-label-pos
--freeze-left
--freeze-right
--gap
--gap-line
--ghost
--gutter
--gutter-raw
--header
--header-border
--header-first
--header-label
--header-label-pos
--header-lines
--header-lines-border
--height
--highlight-line
--history
--history-size
--hscroll-off
--id-nth
--info
--info-command
--input-border
--input-label
--input-label-pos
--jump-labels
--keep-right
--layout
--listen
--listen-unsafe
--list-border
--list-label
--list-label-pos
--literal
--man
--margin
--marker
--marker-multi-line
--min-height
--no-bold
--no-clear
--no-hscroll
--no-mouse
--no-input
--no-multi-line
--no-scrollbar
--no-separator
--no-unicode
--padding
--pointer
--preview
--preview-border
--preview-label
--preview-label-pos
--preview-window
--print-query
--print0
--prompt
--raw
--read0
--reverse
--scheme
--scroll-off
--scrollbar
--separator
--smart-case
--style
--sync
--tabstop
--tac
--tail
--tiebreak
--tmux
--track
--version
--walker
--walker-root
--walker-skip
--with-nth
--with-shell
--wrap
--wrap-sign
--preview-wrap-sign
--zsh
-0 --exit-0
-1 --select-1
@@ -203,11 +238,11 @@ _fzf_opts_completion() {
return 0
;;
--tiebreak)
COMPREPLY=($(compgen -W "length chunk begin end index" -- "$cur"))
COMPREPLY=($(compgen -W "length chunk pathname begin end index" -- "$cur"))
return 0
;;
--color)
COMPREPLY=($(compgen -W "dark light 16 bw no" -- "$cur"))
COMPREPLY=($(compgen -W "dark light base16 16 bw no" -- "$cur"))
return 0
;;
--layout)
@@ -218,12 +253,21 @@ _fzf_opts_completion() {
COMPREPLY=($(compgen -W "default right hidden inline inline-right" -- "$cur"))
return 0
;;
--wrap)
COMPREPLY=($(compgen -W "char word" -- "$cur"))
return 0
;;
--style)
COMPREPLY=($(compgen -W "default minimal full" -- "$cur"))
return 0
;;
--preview-window)
COMPREPLY=($(compgen -W "
default
hidden
nohidden
wrap
wrap-word
nowrap
cycle
nocycle
@@ -232,6 +276,7 @@ _fzf_opts_completion() {
left
right
rounded border border-rounded
border-line
sharp border-sharp
border-bold
border-block
@@ -245,14 +290,16 @@ _fzf_opts_completion() {
border-left
border-right
follow
nofollow" -- "$cur"))
nofollow
info
noinfo" -- "$cur"))
return 0
;;
--border)
COMPREPLY=($(compgen -W "rounded sharp bold block thinblock double horizontal vertical top bottom left right none" -- "$cur"))
--border | --list-border | --header-border | --header-lines-border | --footer-border | --input-border | --preview-border)
COMPREPLY=($(compgen -W "line rounded sharp bold block thinblock double horizontal vertical top bottom left right none" -- "$cur"))
return 0
;;
--border-label-pos | --preview-label-pos)
--border-label-pos | --preview-label-pos | --list-label-pos | --header-label-pos | --footer-label-pos | --input-label-pos)
COMPREPLY=($(compgen -W "center bottom top" -- "$cur"))
return 0
;;
@@ -322,19 +369,22 @@ __fzf_generic_path_completion() {
matches=$(
export FZF_DEFAULT_OPTS=$(__fzf_defaults "--reverse --scheme=path" "${FZF_COMPLETION_OPTS-} $2")
unset FZF_DEFAULT_COMMAND FZF_DEFAULT_OPTS_FILE
if [[ $1 =~ dir ]]; then
eval "rest=(${FZF_COMPLETION_DIR_OPTS-})"
else
eval "rest=(${FZF_COMPLETION_PATH_OPTS-})"
fi
if declare -F "$1" > /dev/null; then
eval "$1 $(printf %q "$dir")" | __fzf_comprun "$4" -q "$leftover"
eval "$1 $(builtin printf %q "$dir")" | __fzf_comprun "$4" -q "$leftover" "${rest[@]}"
else
if [[ $1 =~ dir ]]; then
walker=dir,follow
eval "rest=(${FZF_COMPLETION_DIR_OPTS-})"
else
walker=file,dir,follow,hidden
eval "rest=(${FZF_COMPLETION_PATH_OPTS-})"
fi
__fzf_comprun "$4" -q "$leftover" --walker "$walker" --walker-root="$dir" "${rest[@]}"
fi | while read -r item; do
printf "%q " "${item%$3}$3"
builtin printf "%q " "${item%$3}$3"
done
)
matches=${matches% }
@@ -344,9 +394,9 @@ __fzf_generic_path_completion() {
else
COMPREPLY=("$cur")
fi
# To redraw line after fzf closes (printf '\e[5n')
# To redraw line after fzf closes (builtin printf '\e[5n')
bind '"\e[0n": redraw-current-line' 2> /dev/null
printf '\e[5n'
builtin printf '\e[5n'
return 0
fi
dir=$(command dirname "$dir")
@@ -404,7 +454,7 @@ _fzf_complete() {
COMPREPLY=("$cur")
fi
bind '"\e[0n": redraw-current-line' 2> /dev/null
printf '\e[5n'
builtin printf '\e[5n'
return 0
else
_fzf_handle_dynamic_completion "$cmd" "${rest[@]}"
@@ -476,7 +526,7 @@ _fzf_proc_completion_post() {
# # Set the local attribute for any non-local variable that is set by _known_hosts_real()
# local COMPREPLY=()
# _known_hosts_real ''
# printf '%s\n' "${COMPREPLY[@]}" | command sort -u --version-sort
# builtin printf '%s\n' "${COMPREPLY[@]}" | command sort -u --version-sort
# }
if ! declare -F __fzf_list_hosts > /dev/null; then
__fzf_list_hosts() {
+169
View File
@@ -0,0 +1,169 @@
# ____ ____
# / __/___ / __/
# / /_/_ / / /_
# / __/ / /_/ __/
# /_/ /___/_/ completion.fish
#
# - $FZF_COMPLETION_OPTS
# - $FZF_EXPANSION_OPTS
# The oldest supported fish version is 3.4.0. For this message being able to be
# displayed on older versions, the command substitution syntax $() should not
# be used anywhere in the script, otherwise the source command will fail.
if string match -qr -- '^[12]\\.|^3\\.[0-3]' $version
echo "fzf completion script requires fish version 3.4.0 or newer." >&2
return 1
else if not command -q fzf
echo "fzf was not found in path." >&2
return 1
end
function fzf_complete -w fzf -d 'fzf command completion and wildcard expansion search'
# Restore the default shift-tab behavior on tab completions
if commandline --paging-mode
commandline -f complete-and-search
return
end
# Remove any trailing unescaped backslash from token and update command line
set -l -- token (string replace -r -- '(?<!\\\\)(?:\\\\\\\\)*\\K\\\\$' '' (commandline -t | string collect) | string collect)
commandline -rt -- $token
# Remove any line breaks from token
set -- token (string replace -ra -- '\\\\\\n' '' $token | string collect)
# regex: Match token with unescaped/unquoted glob character
set -l -- r_glob '^(?:[^\'"\\\\*]|\\\\[\\S\\s]|\'(?:\\\\[\\S\\s]|[^\'\\\\])*\'|"(?:\\\\[\\S\\s]|[^"\\\\])*")*\\*[\\S\\s]*$'
# regex: Match any unbalanced quote character
set -l -- r_quote '^(?>(?:\\\\[\\s\\S]|"(?:[^"\\\\]|\\\\[\\s\\S])*"|\'(?:[^\'\\\\]|\\\\[\\s\\S])*\'|[^\'"\\\\]+)*)\\K[\'"]'
# The expansion pattern is the token with any open quote closed, or is empty.
set -l -- glob_pattern (string match -r -- $r_glob $token | string collect)(string match -r -- $r_quote $token | string collect -a)
set -l -- cl_tokenize_opt '--tokens-expanded'
string match -q -- '3.*' $version
and set -- cl_tokenize_opt '--tokenize'
# Set command line tokens without any leading variable definitions or launcher
# commands (including their options, but not any option arguments).
set -l -- r_cmd '^(?:(?:builtin|command|doas|env|sudo|\\w+=\\S*|-\\S+)\\s+)*\\K[\\s\\S]+'
set -l -- cmd (commandline $cl_tokenize_opt --input=(commandline -pc | string match -r $r_cmd))
test -z "$token"
and set -a -- cmd ''
# Set fzf options
test -z "$FZF_TMUX_HEIGHT"
and set -l -- FZF_TMUX_HEIGHT 40%
set -lax -- FZF_DEFAULT_OPTS \
"--height=$FZF_TMUX_HEIGHT --min-height=20+ --bind=ctrl-z:ignore" \
(test -r "$FZF_DEFAULT_OPTS_FILE"; and string join -- ' ' <$FZF_DEFAULT_OPTS_FILE) \
$FZF_DEFAULT_OPTS '--bind=alt-r:toggle-raw --multi --wrap=word --reverse' \
(if test -n "$glob_pattern"; string collect -- $FZF_EXPANSION_OPTS; else;
string collect -- $FZF_COMPLETION_OPTS; end; string escape -n -- $argv) \
--with-shell=(status fish-path)\\ -c
set -lx FZF_DEFAULT_OPTS_FILE
set -l -- fzf_cmd fzf
test "$FZF_TMUX" = 1
and set -- fzf_cmd fzf-tmux $FZF_TMUX_OPTS -d$FZF_TMUX_HEIGHT --
set -l result
# Get the completion list from stdin when it's not a tty
if not isatty stdin
set -l -- custom_post_func _fzf_post_complete_$cmd[1]
functions -q $custom_post_func
or set -- custom_post_func _fzf_complete_$cmd[1]_post
if functions -q $custom_post_func
$fzf_cmd | $custom_post_func $cmd | while read -l r; set -a -- result $r; end
else if string match -q -- '*--print0*' "$FZF_DEFAULT_OPTS"
$fzf_cmd | while read -lz r; set -a -- result $r; end
else
$fzf_cmd | while read -l r; set -a -- result $r; end
end
# Wildcard expansion
else if test -n "$glob_pattern"
# Set the command to be run by fzf, so there is a visual indicator and an
# easy way to abort on long recursive searches.
set -lx -- FZF_DEFAULT_COMMAND "for i in $glob_pattern;" \
'test -d "$i"; and string match -qv -- "*/" $i; and set -- i $i/;' \
'string join0 -- $i; end'
set -- result (string escape -n -- ($fzf_cmd --read0 --print0 --scheme=path --no-multi-line | string split0))
# Command completion
else
# Call custom function if defined
set -l -- custom_func _fzf_complete_$cmd[1]
if functions -q $custom_func; and not set -q __fzf_no_custom_complete
set -lx __fzf_no_custom_complete
$custom_func $cmd
return
end
# Workaround for complete not having newlines in results
if string match -qr -- '\\n' $token
set -- token (string replace -ra -- '(?<!\\\\)(?:\\\\\\\\)*\\K\\\\\$' '\\\\\\\\\$' $token | string collect)
set -- token (string unescape -- $token | string collect)
set -- token (string replace -ra -- '\\n' '\\\\n' $token | string collect)
end
set -- list (complete -C --escape -- (string join -- ' ' (commandline -pc $cl_tokenize_opt) $token | string collect))
if test -n "$list"
# Get the initial tabstop value
if set -l -- tabstop (string match -rga -- '--tabstop[= ](?:0*)([1-9]\\d+|[4-9])' "$FZF_DEFAULT_OPTS")[-1]
set -- tabstop (math $tabstop - 4)
else
set -- tabstop 4
end
# Determine the tabstop length for description alignment
set -l -- max_columns (math $COLUMNS - 40)
for i in $list[1..500]
set -l -- item (string split -f 1 -- \t $i)
and set -l -- len (string length -V -- $item)
and test "$len" -gt "$tabstop" -a "$len" -lt "$max_columns"
and set -- tabstop $len
end
set -- tabstop (math $tabstop + 4)
set -- result (string collect -- $list | $fzf_cmd --delimiter="\t" --tabstop=$tabstop --wrap-sign=\t"↳ " --accept-nth=1)
end
end
# Update command line
if test -n "$result"
# No extra space after single selection that ends with path separator
set -l -- tail ' '
test (count $result) -eq 1
and string match -q -- '*/' "$result"
and set -- tail ''
commandline -rt -- (string join -- ' ' $result)$tail
end
commandline -f repaint
end
function _fzf_complete
set -l fzf_args
for i in $argv
string match -q -- '--' $i; and break
set -a -- fzf_args $i
end
fzf_complete $fzf_args
end
# Bind to shift-tab
if string match -qr -- '^\\d\\d+|^[4-9]' $version
bind shift-tab fzf_complete
bind -M insert shift-tab fzf_complete
else
bind -k btab fzf_complete
bind -M insert -k btab fzf_complete
end
+489
View File
@@ -0,0 +1,489 @@
# ____ ____
# / __/___ / __/
# / /_/_ / / /_
# / __/ / /_/ __/
# /_/ /___/_/ completion.nu
# An implementation of completion.nu
# This loads FZF as a Nushell External Completer
# https://www.nushell.sh/cookbook/external_completers.html
# --- Default Environment Variables ---
# These can be overridden in your config.nu or environment.
# Example: $env.FZF_COMPLETION_TRIGGER = "!<TAB>"
# - $env.FZF_TMUX (default: 0)
# - $env.FZF_TMUX_OPTS (default: empty)
# - $env.FZF_TMUX_HEIGHT (default: 40%)
# - $env.FZF_COMPLETION_TRIGGER (default: '**')
# - $env.FZF_COMPLETION_OPTS (default: empty)
# - $env.FZF_COMPLETION_PATH_OPTS (default: empty)
# - $env.FZF_COMPLETION_DIR_OPTS (default: empty)
$env.FZF_COMPLETION_TRIGGER = $env.FZF_COMPLETION_TRIGGER? | default '**'
# Options for fzf completion in general. e.g. '--border'
$env.FZF_COMPLETION_OPTS = $env.FZF_COMPLETION_OPTS? | default ''
# Options specific to path completion. e.g. '--extended'
$env.FZF_COMPLETION_PATH_OPTS = $env.FZF_COMPLETION_PATH_OPTS? | default ''
# Options specific to directory completion. e.g. '--extended'
$env.FZF_COMPLETION_DIR_OPTS = $env.FZF_COMPLETION_DIR_OPTS? | default ''
$env.FZF_COMPLETION_DIR_COMMANDS = $env.FZF_COMPLETION_DIR_COMMANDS? | default ['cd', 'pushd', 'rmdir']
# --- Helper Functions ---
# Helper to build default fzf options list
def __fzf_defaults_completion [prepend: string, append: string]: nothing -> string {
let base = $"--height ($env.FZF_TMUX_HEIGHT? | default '40%') --min-height 20+ --bind=ctrl-z:ignore ($prepend)"
let opts_file = if ($env.FZF_DEFAULT_OPTS_FILE? | default '' | is-not-empty) {
try { open --raw ($env.FZF_DEFAULT_OPTS_FILE) | str trim } catch { '' }
} else {
''
}
let default_opts = $env.FZF_DEFAULT_OPTS? | default ''
$"($base) ($opts_file) ($default_opts) ($append)" | str trim
}
# Wrapper for running fzf or fzf-tmux
def __fzf_comprun [ context_name: string # e.g., "fzf-completion" , "fzf-helper" - mainly for potential debugging
, query: string # The initial query string for fzf
, fzf_opts_arg: list<string> # Remaining options for fzf/fzf-tmux
] {
let stdin_content = try {
# Collect stdin into a single string. Adjust if structured data is expected.
$in | into string
} catch {
null # Set to null if there's no stdin or an error occurs reading it
}
let fzf_default_opts = (__fzf_defaults_completion "" ($env.FZF_COMPLETION_OPTS | default ''))
let fzf_prefinal_opt = ['--query', $query, '--reverse'] | append $fzf_opts_arg
# Get the configured height, defaulting to '40%'
let height_opt = $env.FZF_TMUX_HEIGHT? | default '40%'
# Determine if fzf should generate its own candidates via walker
let has_walker = ($fzf_prefinal_opt | find '--walker' | is-not-empty)
# Check for custom comprun function (Nu equivalent)
if (which _fzf_comprun | is-not-empty) {
# Note: Nushell doesn't have a direct equivalent to Zsh/Bash `type -t _fzf_comprun`.
# This check assumes a user might define a custom command named `_fzf_comprun`.
_fzf_comprun $context_name $query ...$fzf_prefinal_opt # Pass args correctly to custom function
} else if ($env.TMUX_PANE? | default '' | into string | is-not-empty) and (($env.FZF_TMUX? | default 0) != 0 or ($env.FZF_TMUX_OPTS? | is-not-empty)) {
# Running inside tmux, use fzf-tmux
let final_fzf_opts = if ($env.FZF_TMUX_OPTS? | is-not-empty) {
$env.FZF_TMUX_OPTS | split row ' ' | append ['--'] | append $fzf_prefinal_opt
} else {
# Use the default -d option with the configured height for fzf-tmux
['-d', $height_opt, '--'] | append $fzf_prefinal_opt
}
if $has_walker or ($stdin_content == null) {
with-env { FZF_DEFAULT_OPTS: $fzf_default_opts, FZF_DEFAULT_OPTS_FILE: '' } { fzf-tmux ...$final_fzf_opts }
} else {
$stdin_content | with-env { FZF_DEFAULT_OPTS: $fzf_default_opts, FZF_DEFAULT_OPTS_FILE: '' } { fzf-tmux ...$final_fzf_opts }
}
} else {
# Not in tmux or not configured for fzf-tmux, use fzf directly
let final_fzf_opts = $fzf_prefinal_opt
if $has_walker or ($stdin_content == null) {
with-env { FZF_DEFAULT_OPTS: $fzf_default_opts, FZF_DEFAULT_OPTS_FILE: '' } { fzf ...$final_fzf_opts }
} else {
$stdin_content | with-env { FZF_DEFAULT_OPTS: $fzf_default_opts, FZF_DEFAULT_OPTS_FILE: '' } { fzf ...$final_fzf_opts }
}
}
}
# Generate host list for ssh/telnet
def __fzf_list_hosts [] {
# Translate the Zsh pipeline using Nu commands and external tools
let ssh_configs = try { open ~/.ssh/config | lines } catch { [] }
let ssh_configs_d = try { open ~/.ssh/config.d/* | lines } catch { [] }
let ssh_config_global = try { open /etc/ssh/ssh_config | lines } catch { [] }
let known_hosts = try { open ~/.ssh/known_hosts | lines } catch { [] }
let hosts_file = try { open /etc/hosts | lines } catch { [] }
[
(
# Process ssh config files
$ssh_configs | append $ssh_configs_d | append $ssh_config_global
| where {|it| ($it | str downcase | str starts-with 'host') or ($it | str downcase | str starts-with 'hostname') }
| parse --regex '^\s*host(?:name)?\s+(?<hosts>.+)' # Extract hosts after keyword
| default { hosts: null } # Handle lines that don't match regex
| get hosts
| where {|it| $it != null }
| split row ' '
| where {|it| not ($it =~ '[*?%]') } # Exclude patterns containing *, ?, or %
)
(
# Process known_hosts file
$known_hosts | parse --regex '^(?:\[)?(?<hosts>[a-z0-9.,:_-]+)' # Extract hostnames (possibly in [], possibly comma-separated) - added underscore
| default { hosts: null }
| get hosts
| where {|it| $it != null }
| each { |it| $it | split row ',' } # Split comma-separated hosts if any
| flatten
)
(
# Process /etc/hosts file
$hosts_file | where { |it| not ($it | str starts-with '#') } # Ignore comments
| where { |it| not ($it | str trim | is-empty) } # Ignore empty lines
| where { |it| not ($it | str contains '0.0.0.0') } # Ignore 0.0.0.0
| str replace --regex '#.*$' '' # Remove trailing comments
| parse --regex '^\s*\S+\s+(?<hosts>.+)' # Extract hosts part (after IP)
| default { hosts: null }
| get hosts
| where {|it| $it != null }
| split row ' ' # Split multiple hosts on the same line
)
]
| flatten # Combine all lists into a single stream
| where {|it| not ($it | is-empty) } # Remove empty entries
| sort | uniq # Sort and remove duplicates
}
# Base function for path/directory completion
def __fzf_generic_path_completion [ prefix: string # The text before the trigger
, fzf_opts_arg: list<string> # Extra options for fzf
, suffix: string # Suffix to add to selection (e.g. , "/")
] {
# --- Determine walker root and initial query from the prefix ---
mut walker_root = "."
mut initial_query = ""
if ($prefix | is-empty) {
# Case: "**"
$walker_root = "."
$initial_query = ""
} else if ($prefix | str contains (char separator)) {
# Case: "dir/subdir/partial**" or "dir/**"
$walker_root = $prefix | path dirname
$initial_query = $prefix | path basename
# Handle edge case where prefix ends with separator, e.g., "dir/"
if ($prefix | str ends-with (char separator)) {
# Remove trailing separator to get the intended directory
$walker_root = $prefix | str substring 0..-2
$initial_query = ""
}
# Ensure walker_root isn't empty if prefix was like "/file**"
# or if path dirname returned empty string for some reason (e.g. prefix="file/")
if ($walker_root | is-empty) {
if ($prefix | str starts-with (char separator)) {
$walker_root = (char separator)
} else if ($prefix | str ends-with (char separator)) {
$walker_root = $prefix | str substring 0..-2
} else { $walker_root = "." } # Fallback if dirname weirdly fails
}
} else {
# Case: "partial**" (no slashes)
$walker_root = "."
$initial_query = $prefix
}
# --- Prepare FZF options ---
let completion_type_opts = if $suffix == '/' {
$env.FZF_COMPLETION_DIR_OPTS? | default '' | split row ' ' | where {not ($in | is-empty)}
} else {
$env.FZF_COMPLETION_PATH_OPTS? | default '' | split row ' ' | where {not ($in | is-empty)}
}
let walker_type = if ($suffix == '/') {
"dir,follow"
} else {
"file,dir,follow,hidden"
}
# Expand tilde so fzf receives a valid absolute path as walker-root
let needs_tilde_rewrite = ($walker_root | str starts-with '~')
let walker_root_expanded = ($walker_root | path expand)
# Use the 'walker_root' calculated at the beginning
let fzf_all_opts = ["--scheme=path", "--walker", $walker_type, "--walker-root", $walker_root_expanded] | append $fzf_opts_arg
| append $completion_type_opts
# Call FZF run
let fzf_selection = ( __fzf_comprun "fzf-path-completion-walker" $initial_query $fzf_all_opts ) | str trim
# --- Return Result ---
if ($fzf_selection | is-not-empty) {
# Restore tilde prefix if the user originally typed ~/
let home = $nu.home-dir | path expand
let result = if $needs_tilde_rewrite {
$fzf_selection | lines | each {|line| $line | str replace $home '~' } | str join ' '
} else {
$fzf_selection | lines | str join ' '
}
[$result]
} else {
[]
}
}
# Specific path completion wrapper
def _fzf_path_completion [prefix: string] {
# Zsh args: base, lbuf, _fzf_compgen_path, "-m", "", " "
# Nu: prefix, empty command name (use find), ["-m"], "", " "
__fzf_generic_path_completion $prefix ["-m"] ""
}
# General completion helper for commands that feed a list to fzf
# This is called by ssh, kill, and user-defined completers.
def _fzf_complete [ query: string # The initial query string for fzf
, data_gen_closure: closure # Closure that generates candidates
, fzf_opts_arg: list<string> # Extra options for fzf (like -m, +m)
, --post_process_closure: closure # Closure to process the selected item (optional)
] {
# Generate candidates using the provided command
let candidates = try {
do $data_gen_closure
} catch {
# Capture the actual error object provided by the catch block
let actual_error = $in
# Print a more informative error message including the actual error details
print -e $"Error executing data_gen closure. Closure code: ($data_gen_closure). Actual error: ($actual_error)"
[]
}
# Run fzf and get selection
let fzf_selection = $candidates | to text
| __fzf_comprun "fzf-helper" $query $fzf_opts_arg
| str trim # Trim potential trailing newline from fzf
# Apply post-processing if closure provided and selection is not empty
let processed_selection = if ($fzf_selection | is-not-empty) and ($post_process_closure | is-not-empty) {
# Call the post-processing closure with the selection
try {
do $post_process_closure $fzf_selection
} catch {
print -e $"Error executing post_process closure: ($post_process_closure)"
$fzf_selection # Return original selection on error
}
} else {
$fzf_selection
}
if not ($processed_selection | is-empty) {
[($processed_selection | lines | str join ' ')]
} else {
[]
}
}
# SSH/Telnet completion
def _fzf_complete_ssh [ prefix: string
, input_line_before_trigger: string
] {
let words = ($input_line_before_trigger | split row ' ')
let word_count = $words | length
# Find the index of the word being completed (which is the prefix)
# If prefix is empty, completion happens after a space, index is word_count
# If prefix is not empty, it's the last word, index is word_count - 1
let completion_index = if ($prefix | is-empty) { $word_count } else { $word_count - 1 }
mut handled = false
mut completion_result = [] # List of completion strings to return
# Check for -i, -F, -E flags immediately preceding the cursor position
if $completion_index > 0 {
let prev_arg = ($words | get ($completion_index - 1))
if ($prev_arg in ['-i', '-F', '-E']) {
$handled = true
# Call path completion with the current prefix
$completion_result = (_fzf_path_completion $prefix)
}
}
# If not handled by path completion, do host completion
if not $handled {
let user_part = if ($prefix | str contains "@") { ($prefix | split row "@" | first) + "@" } else { "" }
# The part after '@' (or the whole prefix if no '@') is the initial query for fzf
let query = if ($prefix | str contains "@") { $prefix | split row "@" | last } else { $prefix }
let host_candidates_gen = {||
__fzf_list_hosts
| each {|host_item| $user_part + $host_item } # Prepend user@ if present in prefix
}
# Zsh options: +m -- ; Nu: pass ["+m"]
# Pass the host part of the prefix to _fzf_complete for the initial query
let selected_host = (_fzf_complete $query $host_candidates_gen ["+m"]) # Pass host_prefix here
if not ($selected_host | is-empty) {
$completion_result = $selected_host # _fzf_complete returns a list
}
}
$completion_result
}
# Kill completion post-processor (extracts PID)
def _fzf_complete_kill_post_get_pid [selected_line: string] {
# Assuming standard ps output where PID is the second column
$selected_line | lines | each { $in | from ssv --noheaders | get 0.column1 } | to text
}
# Kill completion to get process PID
def _fzf_complete_kill [query: string] {
let ps_gen_closure = {|| # Define ps generator as a closure
# Try standard ps, then busybox, then cygwin format approximation
# Use `^ps` to ensure external command execution
try {
^ps -eo user,pid,ppid,start,time,command | complete | if $in.exit_code == 0 { $in.stdout | lines } else { error make {msg: "ps failed"} }
} catch {
try {
^ps -eo user,pid,ppid,time,args | complete | if $in.exit_code == 0 { $in.stdout | lines } else { error make {msg: "ps failed"} }
} catch {
try {
^ps --everyone --full --windows | complete | if $in.exit_code == 0 { $in.stdout | lines } else { error make {msg: "ps failed"} }
} catch {
print -e "Error: ps command failed."
[] # Return empty list on failure
}
}
}
}
# Note: Complex Zsh FZF bindings for kill (click-header transformer) are omitted for simplicity.
# Users can set custom bindings via FZF_DEFAULT_OPTS if needed.
let kill_post_closure = {|selected_line| _fzf_complete_kill_post_get_pid $selected_line }
let fzf_opts = ["-m", "--header-lines=1", "--no-preview", "--wrap", "--color", "fg:dim,nth:regular"]
_fzf_complete $query $ps_gen_closure $fzf_opts --post_process_closure $kill_post_closure
}
# --- Main FZF External Completer ---
# This function is registered with Nushell's external completion system.
# It gets called when Tab is pressed.
let fzf_external_completer = {|spans|
let trigger: string = $env.FZF_COMPLETION_TRIGGER? | default '**'
if ($trigger | is-empty) { return null } # Cannot work with empty trigger
if (($spans | length ) == 0) { return null } # Nothing to complete
let last_span = $spans | last
if ($last_span | str ends-with $trigger) {
# --- Trigger Found ---
# Skip sudo to determine the actual command
let cmd_spans = if ($spans | first) == "sudo" { $spans | skip 1 } else { $spans }
let cmd_word = ($cmd_spans | first | default "")
# Calculate the prefix (part before the trigger in the last span)
let prefix = $last_span | str substring 0..(-1 * ($trigger | str length) - 1)
# Reconstruct the line content *before* the trigger for context
# This is an approximation based on spans
let line_without_trigger = $cmd_spans | take (($cmd_spans | length) - 1) | append $prefix | str join ' '
# --- Dispatch to Completer ---
mut completion_results = [] # Will hold the list of strings from the completer
# Check for user-defined completer in $env.FZF_COMPLETERS first.
# Users can define custom completers in their config.nu as a record of closures:
# $env.FZF_COMPLETERS = { git: {|prefix, spans| ... }, docker: {|prefix, spans| ... } }
# Each closure receives the prefix (text before the trigger) and the full
# command spans (e.g. ["pacman", "-S", "vim**"]), and should return either:
# - a list of candidate strings, or
# - a record { candidates: [...], opts: [...], post: {|sel| ...} } to pass
# custom fzf options and/or a post-processing closure.
# See shell/completion-examples.nu for examples.
let user_completers = ($env.FZF_COMPLETERS? | default {})
if ($cmd_word in $user_completers) {
let user_gen = ($user_completers | get $cmd_word)
let user_result = (do $user_gen $prefix $cmd_spans)
if ($user_result | describe | str starts-with 'record') {
let candidates = ($user_result | get candidates)
let fzf_opts = ($user_result | get opts? | default ["-m"])
let post = ($user_result | get post? | default null)
if ($post != null) {
$completion_results = (_fzf_complete $prefix {|| $candidates} $fzf_opts --post_process_closure $post)
} else {
$completion_results = (_fzf_complete $prefix {|| $candidates} $fzf_opts)
}
} else {
$completion_results = (_fzf_complete $prefix {|| $user_result} ["-m"])
}
} else {
match $cmd_word {
"ssh" | "scp" | "sftp" | "telnet" => { $completion_results = (_fzf_complete_ssh $prefix $line_without_trigger) }
"kill" => { $completion_results = (_fzf_complete_kill $prefix) }
_ if ($cmd_word in $env.FZF_COMPLETION_DIR_COMMANDS) => {
$completion_results = (__fzf_generic_path_completion $prefix [] "/")
}
_ => {
# Default to path completion if no specific command matches
$completion_results = (_fzf_path_completion $prefix)
}
}
}
# --- Return Results ---
# The _fzf_... functions return a list of completion strings.
# Nushell's completer expects the suggestions for the token being completed (prefix + trigger).
# The results from the helper functions should be the final desired strings.
# We don't need to manually add spaces; Nushell handles that.
$completion_results # Return the list directly
} else {
# --- Trigger Not Found ---
# Return null to let Nushell fall back to other completers (e.g., default file completion).
null
}
}
# --- WRAPPER AND REGISTRATION ---
# Guard against re-sourcing: wrapping the completer multiple times would
# nest wrappers and grow the call chain on every reload.
if ($env.__fzf_completer_registered? | default false) != true {
# Get the currently configured external completer, if any exists
let previous_external_completer = $env.config? | get completions? | get external? | get completer?
# Define the new wrapper completer
let fzf_wrapper_completer = {|spans|
# 1. Try the FZF completer logic first
let fzf_result = do $fzf_external_completer $spans
# 2. If FZF returned a result (a list, even an empty one), return it.
# `null` means FZF didn't handle it because the trigger wasn't present.
if $fzf_result != null {
$fzf_result
} else {
# 3. FZF didn't handle it, so call the previous completer (if it exists).
if $previous_external_completer != null {
do $previous_external_completer $spans
} else {
# 4. No previous completer, and FZF didn't handle it. Return null.
null
}
}
}
# Register the new wrapper completer
# This ensures external completions are enabled and sets our wrapper.
$env.config = $env.config | upsert completions {
external: {
enable: true
completer: $fzf_wrapper_completer
}
}
$env.__fzf_completer_registered = true
}
# vim: set sts=2 ts=2 sw=2 tw=120 et :
+12 -8
View File
@@ -4,8 +4,6 @@
# / __/ / /_/ __/
# /_/ /___/_/ completion.zsh
#
# - $FZF_TMUX (default: 0)
# - $FZF_TMUX_OPTS (default: empty)
# - $FZF_COMPLETION_TRIGGER (default: '**')
# - $FZF_COMPLETION_OPTS (default: empty)
# - $FZF_COMPLETION_PATH_OPTS (default: empty)
@@ -102,9 +100,9 @@ if [[ -o interactive ]]; then
# the changes. See code comments in "common.sh" for the implementation details.
__fzf_defaults() {
printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
builtin printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
command cat "${FZF_DEFAULT_OPTS_FILE-}" 2> /dev/null
printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
builtin printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
}
__fzf_exec_awk() {
@@ -115,7 +113,10 @@ __fzf_exec_awk() {
elif command -v mawk > /dev/null 2>&1; then
local n x y z d
IFS=' .' read -r n x y z d <<< $(command mawk -W version 2> /dev/null)
[[ $n == mawk ]] && ((d >= 20230302 && (x * 1000 + y) * 1000 + z >= 1003004)) && __fzf_awk=mawk
[[ $n == mawk ]] &&
(((x * 1000 + y) * 1000 + z >= 1003004)) 2> /dev/null &&
((d >= 20230302)) 2> /dev/null &&
__fzf_awk=mawk
fi
fi
LC_ALL=C exec "$__fzf_awk" "$@"
@@ -171,15 +172,18 @@ __fzf_generic_path_completion() {
export FZF_DEFAULT_OPTS
FZF_DEFAULT_OPTS=$(__fzf_defaults "--reverse --scheme=path" "${FZF_COMPLETION_OPTS-}")
unset FZF_DEFAULT_COMMAND FZF_DEFAULT_OPTS_FILE
if [[ $compgen =~ dir ]]; then
rest=${FZF_COMPLETION_DIR_OPTS-}
else
rest=${FZF_COMPLETION_PATH_OPTS-}
fi
if declare -f "$compgen" > /dev/null; then
eval "$compgen $(printf %q "$dir")" | __fzf_comprun "$cmd_word" ${(Q)${(Z+n+)fzf_opts}} -q "$leftover"
eval "$compgen $(printf %q "$dir")" | __fzf_comprun "$cmd_word" ${(Q)${(Z+n+)fzf_opts}} -q "$leftover" ${(Q)${(Z+n+)rest}}
else
if [[ $compgen =~ dir ]]; then
walker=dir,follow
rest=${FZF_COMPLETION_DIR_OPTS-}
else
walker=file,dir,follow,hidden
rest=${FZF_COMPLETION_PATH_OPTS-}
fi
__fzf_comprun "$cmd_word" ${(Q)${(Z+n+)fzf_opts}} -q "$leftover" --walker "$walker" --walker-root="$dir" ${(Q)${(Z+n+)rest}} < /dev/tty
fi | while read -r item; do
+38 -14
View File
@@ -4,7 +4,6 @@
# / __/ / /_/ __/
# /_/ /___/_/ key-bindings.bash
#
# - $FZF_TMUX_OPTS
# - $FZF_CTRL_T_COMMAND
# - $FZF_CTRL_T_OPTS
# - $FZF_CTRL_R_COMMAND
@@ -25,9 +24,9 @@ if [[ $- =~ i ]]; then
# the changes. See code comments in "common.sh" for the implementation details.
__fzf_defaults() {
printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
builtin printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
command cat "${FZF_DEFAULT_OPTS_FILE-}" 2> /dev/null
printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
builtin printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
}
__fzf_exec_awk() {
@@ -38,7 +37,10 @@ __fzf_exec_awk() {
elif command -v mawk > /dev/null 2>&1; then
local n x y z d
IFS=' .' read -r n x y z d <<< $(command mawk -W version 2> /dev/null)
[[ $n == mawk ]] && ((d >= 20230302 && (x * 1000 + y) * 1000 + z >= 1003004)) && __fzf_awk=mawk
[[ $n == mawk ]] &&
(((x * 1000 + y) * 1000 + z >= 1003004)) 2> /dev/null &&
((d >= 20230302)) 2> /dev/null &&
__fzf_awk=mawk
fi
fi
LC_ALL=C exec "$__fzf_awk" "$@"
@@ -74,17 +76,35 @@ __fzf_cd__() {
) && printf 'builtin cd -- %q' "$(builtin unset CDPATH && builtin cd -- "$dir" && builtin pwd)"
}
__fzf_history_delete() {
[[ -s $1 ]] || return
local offsets
offsets=($(sort -rnu "$1"))
for offset in "${offsets[@]}"; do
builtin history -d "$offset"
done
if [[ ${#offsets[@]} -gt 0 ]] && shopt -q histappend; then
builtin history -w
fi
}
if command -v perl > /dev/null; then
__fzf_history__() {
local output script
local output script deletefile
deletefile=$(mktemp)
script='BEGIN { getc; $/ = "\n\t"; $HISTCOUNT = $ENV{last_hist} + 1 } s/^[ *]//; s/\n/\n\t/gm; print $HISTCOUNT - $. . "\t$_" if !$seen{$_}++'
output=$(
set +o pipefail
builtin fc -lnr -2147483648 |
last_hist=$(HISTTIMEFORMAT='' builtin history 1) command perl -n -l0 -e "$script" |
FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '"$'\t'"↳ ' --highlight-line ${FZF_CTRL_R_OPTS-} +m --read0") \
FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '"$'\t'"↳ ' --highlight-line --bind 'shift-delete:execute-silent(cat {+f1} >> \"$deletefile\")+exclude-multi' --multi ${FZF_CTRL_R_OPTS-} --read0") \
FZF_DEFAULT_OPTS_FILE='' $(__fzfcmd) --query "$READLINE_LINE"
) || return
)
__fzf_history_delete "$deletefile"
command rm -f "$deletefile"
[[ -n $output ]] || return
READLINE_LINE=$(command perl -pe 's/^\d*\t//' <<< "$output")
if [[ -z $READLINE_POINT ]]; then
echo "$READLINE_LINE"
@@ -94,7 +114,8 @@ if command -v perl > /dev/null; then
}
else # awk - fallback for POSIX systems
__fzf_history__() {
local output script
local output script deletefile
deletefile=$(mktemp)
[[ $(HISTTIMEFORMAT='' builtin history 1) =~ [[:digit:]]+ ]] # how many history entries
script='function P(b) { ++n; sub(/^[ *]/, "", b); if (!seen[b]++) { printf "%d\t%s%c", '$((BASH_REMATCH + 1))' - n, b, 0 } }
NR==1 { b = substr($0, 2); next }
@@ -105,9 +126,12 @@ else # awk - fallback for POSIX systems
set +o pipefail
builtin fc -lnr -2147483648 2> /dev/null | # ( $'\t '<lines>$'\n' )* ; <lines> ::= [^\n]* ( $'\n'<lines> )*
__fzf_exec_awk "$script" | # ( <counter>$'\t'<lines>$'\000' )*
FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '"$'\t'"↳ ' --highlight-line ${FZF_CTRL_R_OPTS-} +m --read0") \
FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '"$'\t'"↳ ' --highlight-line --bind 'shift-delete:execute-silent(cat {+f1} >> \"$deletefile\")+exclude-multi' --multi ${FZF_CTRL_R_OPTS-} --read0") \
FZF_DEFAULT_OPTS_FILE='' $(__fzfcmd) --query "$READLINE_LINE"
) || return
)
__fzf_history_delete "$deletefile"
command rm -f "$deletefile"
[[ -n $output ]] || return
READLINE_LINE=${output#*$'\t'}
if [[ -z $READLINE_POINT ]]; then
echo "$READLINE_LINE"
@@ -118,7 +142,7 @@ else # awk - fallback for POSIX systems
fi
# Required to refresh the prompt after fzf
bind -m emacs-standard '"\er": redraw-current-line'
bind -m emacs-standard '"\C-\e(": redraw-current-line'
bind -m vi-command '"\C-z": emacs-editing-mode'
bind -m vi-insert '"\C-z": emacs-editing-mode'
@@ -127,7 +151,7 @@ bind -m emacs-standard '"\C-z": vi-editing-mode'
if ((BASH_VERSINFO[0] < 4)); then
# CTRL-T - Paste the selected file path into the command line
if [[ ${FZF_CTRL_T_COMMAND-x} != "" ]]; then
bind -m emacs-standard '"\C-t": " \C-b\C-k \C-u`__fzf_select__`\e\C-e\er\C-a\C-y\C-h\C-e\e \C-y\ey\C-x\C-x\C-f\C-y\ey\C-_"'
bind -m emacs-standard '"\C-t": " \C-b\C-k \C-u`__fzf_select__`\e\C-e\C-\e(\C-a\C-y\C-h\C-e\e \C-y\ey\C-x\C-x\C-f\C-y\ey\C-_"'
bind -m vi-command '"\C-t": "\C-z\C-t\C-z"'
bind -m vi-insert '"\C-t": "\C-z\C-t\C-z"'
fi
@@ -137,7 +161,7 @@ if ((BASH_VERSINFO[0] < 4)); then
if [[ -n ${FZF_CTRL_R_COMMAND-} ]]; then
echo "warning: FZF_CTRL_R_COMMAND is set to a custom command, but custom commands are not yet supported for CTRL-R" >&2
fi
bind -m emacs-standard '"\C-r": "\C-e \C-u\C-y\ey\C-u`__fzf_history__`\e\C-e\er"'
bind -m emacs-standard '"\C-r": "\C-e \C-u\C-y\ey\C-u`__fzf_history__`\e\C-e\C-\e("'
bind -m vi-command '"\C-r": "\C-z\C-r\C-z"'
bind -m vi-insert '"\C-r": "\C-z\C-r\C-z"'
fi
@@ -162,7 +186,7 @@ fi
# ALT-C - cd into the selected directory
if [[ ${FZF_ALT_C_COMMAND-x} != "" ]]; then
bind -m emacs-standard '"\ec": " \C-b\C-k \C-u`__fzf_cd__`\e\C-e\er\C-m\C-y\C-h\e \C-y\ey\C-x\C-x\C-d\C-y\ey\C-_"'
bind -m emacs-standard '"\ec": " \C-b\C-k \C-u`__fzf_cd__`\e\C-e\C-\e(\C-m\C-y\C-h\e \C-y\ey\C-x\C-x\C-d\C-y\ey\C-_"'
bind -m vi-command '"\ec": "\C-z\ec\C-z"'
bind -m vi-insert '"\ec": "\C-z\ec\C-z"'
fi
+53 -70
View File
@@ -4,7 +4,6 @@
# / __/ / /_/ __/
# /_/ /___/_/ key-bindings.fish
#
# - $FZF_TMUX_OPTS
# - $FZF_CTRL_T_COMMAND
# - $FZF_CTRL_T_OPTS
# - $FZF_CTRL_R_COMMAND
@@ -12,20 +11,17 @@
# - $FZF_ALT_C_COMMAND
# - $FZF_ALT_C_OPTS
# Key bindings
# ------------
# The oldest supported fish version is 3.1b1. To maintain compatibility, the
# command substitution syntax $(cmd) should never be used, even behind a version
# check, otherwise the source command will fail on fish versions older than 3.4.0.
function fzf_key_bindings
# Check fish version
set -l fish_ver (string match -r '^(\d+).(\d+)' $version 2> /dev/null; or echo 0\n0\n0)
if test \( "$fish_ver[2]" -lt 3 \) -o \( "$fish_ver[2]" -eq 3 -a "$fish_ver[3]" -lt 1 \)
echo "This script requires fish version 3.1b1 or newer." >&2
# The oldest supported fish version is 3.4.0. For this message being able to be
# displayed on older versions, the command substitution syntax $() should not
# be used anywhere in the script, otherwise the source command will fail.
if string match -qr -- '^[12]\\.|^3\\.[0-3]' $version
echo "fzf key bindings script requires fish version 3.4.0 or newer." >&2
return 1
else if not type -q fzf
else if not command -q fzf
echo "fzf was not found in path." >&2
return 1
end
@@ -37,7 +33,7 @@ function fzf_key_bindings
string join ' ' -- \
"--height $FZF_TMUX_HEIGHT --min-height=20+ --bind=ctrl-z:ignore" $argv[1] \
(test -r "$FZF_DEFAULT_OPTS_FILE"; and string join -- ' ' <$FZF_DEFAULT_OPTS_FILE) \
$FZF_DEFAULT_OPTS $argv[2..-1]
$FZF_DEFAULT_OPTS $argv[2..]
end
function __fzfcmd
@@ -56,38 +52,25 @@ function fzf_key_bindings
set -l prefix ''
set -l dir '.'
# Set variables containing the major and minor fish version numbers, using
# a method compatible with all supported fish versions.
set -l -- fish_major (string match -r -- '^\d+' $version)
set -l -- fish_minor (string match -r -- '^\d+\.(\d+)' $version)[2]
set -l -- match_regex '(?<fzf_query>[\\s\\S]*?(?=\\n?$)$)'
set -l -- prefix_regex '^-[^\\s=]+=|^-(?!-)\\S'
# fish v3.3.0 and newer: Don't use option prefix if " -- " is preceded.
set -l -- match_regex '(?<fzf_query>[\s\S]*?(?=\n?$)$)'
set -l -- prefix_regex '^-[^\s=]+=|^-(?!-)\S'
if test "$fish_major" -eq 3 -a "$fish_minor" -lt 3
or string match -q -v -- '* -- *' (string sub -l (commandline -Cp) -- (commandline -p))
set -- match_regex "(?<prefix>$prefix_regex)?$match_regex"
end
# Don't use option prefix if " -- " is preceded.
string match -qv -- '* -- *' (string sub -l (commandline -Cp) -- (commandline -p))
and set -- match_regex "(?<prefix>$prefix_regex)?$match_regex"
# Set $prefix and expanded $fzf_query with preserved trailing newlines.
if test "$fish_major" -ge 4
if string match -qr -- '^\\d\\d+|^[4-9]' $version
# fish v4.0.0 and newer
string match -q -r -- $match_regex (commandline --current-token --tokens-expanded | string collect -N)
else if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2
# fish v3.2.0 - v3.7.1 (last v3)
string match -q -r -- $match_regex (commandline --current-token --tokenize | string collect -N)
eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r -a '^\\\(?=~)|\\\(?=\$\w)' '')
else
# fish older than v3.2.0 (v3.1b1 - v3.1.2)
set -l -- cl_token (commandline --current-token --tokenize | string collect -N)
set -- prefix (string match -r -- $prefix_regex $cl_token)
set -- fzf_query (string replace -- "$prefix" '' $cl_token | string collect -N)
eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r -a '^\\\(?=~)|\\\(?=\$\w)|\\\n\\\n$' '')
string match -q -r -- $match_regex (commandline --current-token --tokenize | string collect -N)
eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r -a '^\\\\(?=~)|\\\\(?=\\$\\w)' '')
end
if test -n "$fzf_query"
# Normalize path in $fzf_query, set $dir to the longest existing directory.
if test \( "$fish_major" -ge 4 \) -o \( "$fish_major" -eq 3 -a "$fish_minor" -ge 5 \)
if string match -qr -- '^\\d\\d+|^4|^3\\.[5-9]' $version
# fish v3.5.0 and newer
set -- fzf_query (path normalize -- $fzf_query)
set -- dir $fzf_query
@@ -95,35 +78,22 @@ function fzf_key_bindings
set -- dir (path dirname $dir)
end
else
# fish older than v3.5.0 (v3.1b1 - v3.4.1)
if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2
# fish v3.2.0 - v3.4.1
string match -q -r -- '(?<fzf_query>^[\s\S]*?(?=\n?$)$)' \
(string replace -r -a -- '(?<=/)/|(?<!^)/+(?!\n)$' '' $fzf_query | string collect -N)
else
# fish v3.1b1 - v3.1.2
set -- fzf_query (string replace -r -a -- '(?<=/)/|(?<!^)/+(?!\n)$' '' $fzf_query | string collect -N)
eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r '\\\n$' '')
end
string match -q -r -- '(?<fzf_query>^[\\s\\S]*?(?=\\n?$)$)' \
(string replace -r -a -- '(?<=/)/|(?<!^)/+(?!\\n)$' '' $fzf_query | string collect -N)
set -- dir $fzf_query
while not test -d "$dir"
set -- dir (dirname -z -- "$dir" | string split0)
end
end
if not string match -q -- '.' $dir; or string match -q -r -- '^\./|^\.$' $fzf_query
if not string match -q -- '.' $dir; or string match -qr -- '^\\.(/|$)' $fzf_query
# Strip $dir from $fzf_query - preserve trailing newlines.
if test "$fish_major" -ge 4
if string match -qr -- '^\\d\\d+|^[4-9]' $version
# fish v4.0.0 and newer
string match -q -r -- '^'(string escape --style=regex -- $dir)'/?(?<fzf_query>[\s\S]*)' $fzf_query
else if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2
# fish v3.2.0 - v3.7.1 (last v3)
string match -q -r -- '^/?(?<fzf_query>[\s\S]*?(?=\n?$)$)' \
(string replace -- "$dir" '' $fzf_query | string collect -N)
string match -q -r -- '^'(string escape --style=regex -- $dir)'/?(?<fzf_query>[\\s\\S]*)' $fzf_query
else
# fish older than v3.2.0 (v3.1b1 - v3.1.2)
set -- fzf_query (string replace -- "$dir" '' $fzf_query | string collect -N)
eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r -a '^/?|\\\n$' '')
string match -q -r -- '^/?(?<fzf_query>[\\s\\S]*?(?=\\n?$)$)' \
(string replace -- "$dir" '' $fzf_query | string collect -N)
end
end
end
@@ -140,13 +110,13 @@ function fzf_key_bindings
set -lx FZF_DEFAULT_OPTS (__fzf_defaults \
"--reverse --walker=file,dir,follow,hidden --scheme=path" \
"$FZF_CTRL_T_OPTS --multi --print0")
"--multi $FZF_CTRL_T_OPTS --print0")
set -lx FZF_DEFAULT_COMMAND "$FZF_CTRL_T_COMMAND"
set -lx FZF_DEFAULT_OPTS_FILE
set -l result (eval (__fzfcmd) --walker-root=$dir --query=$fzf_query | string split0)
and commandline -rt -- (string join -- ' ' $prefix(string escape -- $result))' '
and commandline -rt -- (string join -- ' ' $prefix(string escape -n -- $result))' '
commandline -f repaint
end
@@ -157,24 +127,34 @@ function fzf_key_bindings
set -l -- total_lines (count $command_line)
set -l -- fzf_query (string escape -- $command_line[$current_line])
set -lx FZF_DEFAULT_OPTS (__fzf_defaults '' \
'--nth=2..,.. --scheme=history --multi --wrap-sign="\t↳ "' \
'--bind=\'shift-delete:execute-silent(eval history delete --exact --case-sensitive -- (string escape -n -- {+} | string replace -r -a "^\d*\\\\\\t|(?<=\\\\\\n)\\\\\\t" ""))+reload(eval $FZF_DEFAULT_COMMAND)\'' \
set -lx -- FZF_DEFAULT_OPTS (__fzf_defaults '' \
'--with-nth=2.. --nth=2..,.. --scheme=history --multi --no-multi-line' \
'--no-wrap --wrap-sign="\t\t\t↳ " --preview-wrap-sign="↳ " --freeze-left=1' \
'--bind="alt-enter:become(set -g fzf_temp {+sf3..}; string join0 -- (string split0 -- <$fzf_temp | fish_indent -i); unlink $fzf_temp &>/dev/null)"' \
'--bind="alt-t:change-with-nth(1,3..|3..|2..)"' \
'--bind="shift-delete:execute-silent(eval builtin history delete -Ce -- (string escape -n -- (string split0 -- <{+sf3..})))+reload(eval $FZF_DEFAULT_COMMAND)"' \
"--bind=ctrl-r:toggle-sort,alt-r:toggle-raw --highlight-line $FZF_CTRL_R_OPTS" \
'--accept-nth=2.. --read0 --print0 --with-shell='(status fish-path)\\ -c)
'--accept-nth=3.. --delimiter="\t" --tabstop=4 --read0 --print0 --with-shell='(status fish-path)\\ -c)
# Add dynamic preview options if preview command isn't already set by user
if string match -qvr -- '--preview[= ]' "$FZF_DEFAULT_OPTS"
# Prepend the options to allow user overrides
set -p -- FZF_DEFAULT_OPTS \
'--bind="focus,multi,resize:bg-transform:if test \\"$FZF_COLUMNS\\" -gt 100 -a \\\\( \\"$FZF_SELECT_COUNT\\" -gt 0 -o \\\\( -z \\"$FZF_WRAP\\" -a (string join0 -- <{f3..} | string length) -gt (math $FZF_COLUMNS - (switch $FZF_WITH_NTH; case 2..; echo 13; case 1,3..; echo 25; case 3..; echo 1; end)) \\\\) -o (string split0 -- <{sf3..} | fish_indent | count) -gt 1 \\\\); echo show-preview; else; echo hide-preview; end"' \
'--preview="test \\"$FZF_SELECT_COUNT\\" -gt 0; and string split0 -- <{+sf3..} | fish_indent (string match -q -- 3.\\\\* $version; or echo -- --only-indent) --ansi; and echo -n \\\\n; string collect -- \\\\#\\\\ {1} (string split0 -- <{sf3..}) | fish_indent --ansi"' \
'--preview-window="right,50%,wrap-word,follow,info,hidden"'
end
set -lx FZF_DEFAULT_OPTS_FILE
set -lx FZF_DEFAULT_COMMAND
if type -q perl
set -a FZF_DEFAULT_OPTS '--tac'
set FZF_DEFAULT_COMMAND 'builtin history -z --reverse | command perl -0 -pe \'s/^/$.\t/g; s/\n/\n\t/gm\''
set -lx -- FZF_DEFAULT_COMMAND 'builtin history -z'
# Enable syntax highlighting colors on fish v4.3.3 and newer
if string match -qr -- '^\\d\\d+|^4\\.[4-9]|^4\\.3\\.[3-9]' $version
set -a -- FZF_DEFAULT_OPTS '--ansi'
set -a -- FZF_DEFAULT_COMMAND '--color=always --show-time=(set_color $fish_color_comment)"%F %a %T%t%s%t"(set_color $fish_color_normal)'
else
set FZF_DEFAULT_COMMAND \
'set -l h (builtin history -z --reverse | string split0);' \
'for i in (seq (count $h) -1 1);' \
'string join0 -- $i\t(string replace -a -- \n \n\t $h[$i] | string collect);' \
'end'
set -a -- FZF_DEFAULT_COMMAND '--show-time="%F %a %T%t%s%t"'
end
# Merge history from other sessions before searching
@@ -182,11 +162,11 @@ function fzf_key_bindings
if set -l result (eval $FZF_DEFAULT_COMMAND \| (__fzfcmd) --query=$fzf_query | string split0)
if test "$total_lines" -eq 1
commandline -- (string replace -a -- \n\t \n $result)
commandline -- $result
else
set -l a (math $current_line - 1)
set -l b (math $current_line + 1)
commandline -- $command_line[1..$a] (string replace -a -- \n\t \n $result)
commandline -- $command_line[1..$a] $result
commandline -a -- '' $command_line[$b..-1]
end
end
@@ -234,3 +214,6 @@ function fzf_key_bindings
end
end
# Run setup
fzf_key_bindings
+166
View File
@@ -0,0 +1,166 @@
# ____ ____
# / __/___ / __/
# / /_/_ / / /_
# / __/ / /_/ __/
# /_/ /___/_/ key-bindings.nu
#
# - $FZF_TMUX (default: 0)
# - $FZF_TMUX_OPTS
# - $FZF_TMUX_HEIGHT (default: 40%)
# - $FZF_CTRL_T_COMMAND (set to "" to disable)
# - $FZF_CTRL_T_OPTS
# - $FZF_CTRL_R_COMMAND (set to "" to disable)
# - $FZF_CTRL_R_OPTS
# - $FZF_ALT_C_COMMAND (set to "" to disable)
# - $FZF_ALT_C_OPTS
# Code provided by @igor-ramazanov
# Source: https://github.com/junegunn/fzf/issues/4122#issuecomment-2607368316
# Merge default options in the same order as bash/zsh:
# 1. --height, --min-height, --bind=ctrl-z:ignore, $prepend
# 2. $FZF_DEFAULT_OPTS_FILE contents
# 3. $FZF_DEFAULT_OPTS, $append
def __fzf_defaults [prepend: string, append: string]: nothing -> string {
let base = $"--height ($env.FZF_TMUX_HEIGHT? | default '40%') --min-height 20+ --bind=ctrl-z:ignore ($prepend)"
let opts_file = if ($env.FZF_DEFAULT_OPTS_FILE? | default '' | is-not-empty) {
try { open --raw ($env.FZF_DEFAULT_OPTS_FILE) | str trim } catch { '' }
} else {
''
}
let default_opts = $env.FZF_DEFAULT_OPTS? | default ''
$"($base) ($opts_file) ($default_opts) ($append)" | str trim
}
# Return the fzf command to use: fzf-tmux when inside tmux and
# FZF_TMUX is enabled or FZF_TMUX_OPTS is set, plain fzf otherwise.
def __fzfcmd []: nothing -> list<string> {
let in_tmux = ($env.TMUX_PANE? | default '' | into string | is-not-empty)
if $in_tmux {
let fzf_tmux = ($env.FZF_TMUX? | default 0 | into string)
let fzf_tmux_opts = ($env.FZF_TMUX_OPTS? | default '' | into string)
if ($fzf_tmux != '0') or ($fzf_tmux_opts | is-not-empty) {
let opts = if ($fzf_tmux_opts | is-not-empty) { $fzf_tmux_opts } else { $"-d($env.FZF_TMUX_HEIGHT? | default '40%')" }
return ['fzf-tmux' ...(($opts | split row ' ' | where { $in != '' })) '--']
}
}
['fzf']
}
export-env {
$env.FZF_CTRL_T_OPTS = $env.FZF_CTRL_T_OPTS? | default ""
$env.FZF_CTRL_R_OPTS = $env.FZF_CTRL_R_OPTS? | default ""
$env.FZF_ALT_C_OPTS = $env.FZF_ALT_C_OPTS? | default ""
}
# Directories
const alt_c = {
name: fzf_dirs
modifier: alt
keycode: char_c
mode: [emacs, vi_normal, vi_insert]
event: [
{
send: executehostcommand
cmd: "
let fzf_opts = (__fzf_defaults '--reverse --walker=dir,follow,hidden --scheme=path' $'($env.FZF_ALT_C_OPTS) +m');
let fzfcmd = (__fzfcmd);
let fzf_args = ($fzfcmd | skip 1);
let alt_c_cmd = ($env.FZF_ALT_C_COMMAND? | default null);
let result = if ($alt_c_cmd == null) or ($alt_c_cmd | is-empty) {
with-env { FZF_DEFAULT_OPTS: $fzf_opts, FZF_DEFAULT_OPTS_FILE: '' } { ^($fzfcmd | first) ...$fzf_args }
} else {
let fzf_cmd_str = ($fzfcmd | str join ' ');
let sh_cmd = [$alt_c_cmd '|' $fzf_cmd_str] | str join ' ';
with-env { FZF_DEFAULT_OPTS: $fzf_opts, FZF_DEFAULT_OPTS_FILE: '' } { ^sh -c $sh_cmd }
};
if ($result | is-not-empty) { cd $result };
"
}
]
}
# History
const ctrl_r = {
name: fzf_history
modifier: control
keycode: char_r
mode: [emacs, vi_insert, vi_normal]
event: [
{
send: executehostcommand
cmd: "commandline edit --replace (
let fzf_opts = (__fzf_defaults '' $'--scheme=history --bind=ctrl-r:toggle-sort --wrap-sign \"\t↳ \" --highlight-line ($env.FZF_CTRL_R_OPTS) +m --read0');
let fzfcmd = (__fzfcmd);
let fzf_args = ($fzfcmd | skip 1);
# reverse | uniq: show most recent first, deduplicate keeping the latest.
# Nushell's `history` loads the full history as an in-memory table
# (bounded by $env.config.history.max_size, default 100,000), so
# reverse and uniq run on an already-materialized list. This is O(n)
# but acceptable for typical history sizes; unlike bash/zsh `fc -r`,
# there is no streaming primitive that would let fzf show the latest
# entries before the full list is consumed.
history
| get command
| reverse
| uniq
| str join (char -i 0)
| with-env { FZF_DEFAULT_OPTS: $fzf_opts, FZF_DEFAULT_OPTS_FILE: '' } { ^($fzfcmd | first) ...$fzf_args --query (commandline) }
| decode utf-8
| str trim
)"
}
]
}
# Files
const ctrl_t = {
name: fzf_files
modifier: control
keycode: char_t
mode: [emacs, vi_normal, vi_insert]
event: [
{
send: executehostcommand
cmd: "
let fzf_opts = (__fzf_defaults '--reverse --walker=file,dir,follow,hidden --scheme=path' $'($env.FZF_CTRL_T_OPTS) -m');
let fzfcmd = (__fzfcmd);
let fzf_args = ($fzfcmd | skip 1);
let ctrl_t_cmd = ($env.FZF_CTRL_T_COMMAND? | default null);
let result = if ($ctrl_t_cmd == null) or ($ctrl_t_cmd | is-empty) {
with-env { FZF_DEFAULT_OPTS: $fzf_opts, FZF_DEFAULT_OPTS_FILE: '' } { ^($fzfcmd | first) ...$fzf_args }
} else {
let fzf_cmd_str = ($fzfcmd | str join ' ');
let sh_cmd = [$ctrl_t_cmd '|' $fzf_cmd_str] | str join ' ';
with-env { FZF_DEFAULT_OPTS: $fzf_opts, FZF_DEFAULT_OPTS_FILE: '' } { ^sh -c $sh_cmd }
};
let result = ($result | str replace --all (char newline) ' ' | str trim);
commandline edit --append $result;
commandline set-cursor --end
"
}
]
}
# Helper to check if a binding is enabled. A binding is disabled when
# the corresponding *_COMMAND variable is explicitly set to "".
# When not defined (null), the binding is enabled (using fzf's built-in walker).
def __fzf_binding_enabled [var_name: string]: nothing -> bool {
let val = ($env | get -o $var_name)
# null = not defined = enabled; "" = explicitly disabled
$val == null or ($val | into string | is-not-empty)
}
# Update the $env.config
export-env {
let fzf_names = ['fzf_files', 'fzf_dirs', 'fzf_history']
# Filter out any existing fzf bindings, then re-add the enabled ones.
# This allows re-sourcing to update bindings (e.g. after changing
# FZF_CTRL_T_COMMAND) without creating duplicates.
mut bindings = ($env.config.keybindings | where { |kb| $kb.name not-in $fzf_names })
if (__fzf_binding_enabled 'FZF_ALT_C_COMMAND') { $bindings = ($bindings | append $alt_c) }
if (__fzf_binding_enabled 'FZF_CTRL_R_COMMAND') { $bindings = ($bindings | append $ctrl_r) }
if (__fzf_binding_enabled 'FZF_CTRL_T_COMMAND') { $bindings = ($bindings | append $ctrl_t) }
$env.config.keybindings = $bindings
}
+46 -11
View File
@@ -4,7 +4,6 @@
# / __/ / /_/ __/
# /_/ /___/_/ key-bindings.zsh
#
# - $FZF_TMUX_OPTS
# - $FZF_CTRL_T_COMMAND
# - $FZF_CTRL_T_OPTS
# - $FZF_CTRL_R_COMMAND
@@ -45,9 +44,9 @@ if [[ -o interactive ]]; then
# the changes. See code comments in "common.sh" for the implementation details.
__fzf_defaults() {
printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
builtin printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
command cat "${FZF_DEFAULT_OPTS_FILE-}" 2> /dev/null
printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
builtin printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
}
__fzf_exec_awk() {
@@ -58,7 +57,10 @@ __fzf_exec_awk() {
elif command -v mawk > /dev/null 2>&1; then
local n x y z d
IFS=' .' read -r n x y z d <<< $(command mawk -W version 2> /dev/null)
[[ $n == mawk ]] && ((d >= 20230302 && (x * 1000 + y) * 1000 + z >= 1003004)) && __fzf_awk=mawk
[[ $n == mawk ]] &&
(((x * 1000 + y) * 1000 + z >= 1003004)) 2> /dev/null &&
((d >= 20230302)) 2> /dev/null &&
__fzf_awk=mawk
fi
fi
LC_ALL=C exec "$__fzf_awk" "$@"
@@ -108,8 +110,14 @@ fzf-cd-widget() {
zle redisplay
return 0
fi
# Use subshell expansion to get the absolute PWD of the target dir.
# This allows the recorded shell history to be reused even from a different
# working directory.
# If failed, fallback to the unexpanded path to surface the error to the user.
# NOTE: Don't use the `:a` modifier as it resolves symlinks like `pwd -P`.
dir=$(builtin cd >/dev/null -- "${dir}" && echo "${PWD}" || echo "${dir}")
zle push-line # Clear buffer. Auto-restored on next prompt.
BUFFER="builtin cd -- ${(q)dir:a}"
BUFFER="builtin cd -- ${(q)dir}"
zle accept-line
local ret=$?
unset dir # ensure this doesn't end up appearing in prompt expansion
@@ -125,25 +133,52 @@ fi
# CTRL-R - Paste the selected command from history into the command line
fzf-history-widget() {
local selected
setopt localoptions noglobsubst noposixbuiltins pipefail no_aliases noglob nobash_rematch 2> /dev/null
local selected extracted_with_perl=0
setopt localoptions noglobsubst noposixbuiltins pipefail no_aliases no_glob no_sh_glob no_ksharrays extendedglob 2> /dev/null
# Ensure the module is loaded if not already, and the required features, such
# as the associative 'history' array, which maps event numbers to full history
# lines, are set. Also, make sure Perl is installed for multi-line output.
if zmodload -F zsh/parameter p:{commands,history} 2>/dev/null && (( ${+commands[perl]} )); then
selected="$(printf '%s\t%s\000' "${(kv)history[@]}" |
perl -0 -ne 'if (!$seen{(/^\s*[0-9]+\**\t(.*)/s, $1)}++) { s/\n/\n\t/g; print; }' |
FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '\t↳ ' --highlight-line ${FZF_CTRL_R_OPTS-} --query=${(qqq)LBUFFER} +m --read0") \
FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '\t↳ ' --highlight-line --multi ${FZF_CTRL_R_OPTS-} --query=${(qqq)LBUFFER} --read0") \
FZF_DEFAULT_OPTS_FILE='' $(__fzfcmd))"
extracted_with_perl=1
else
selected="$(fc -rl 1 | __fzf_exec_awk '{ cmd=$0; sub(/^[ \t]*[0-9]+\**[ \t]+/, "", cmd); if (!seen[cmd]++) print $0 }' |
FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '\t↳ ' --highlight-line ${FZF_CTRL_R_OPTS-} --query=${(qqq)LBUFFER} +m") \
FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '\t↳ ' --highlight-line --multi ${FZF_CTRL_R_OPTS-} --query=${(qqq)LBUFFER}") \
FZF_DEFAULT_OPTS_FILE='' $(__fzfcmd))"
fi
local ret=$?
local -a cmds
# Avoid leaking auto assigned values when using backreferences '(#b)'
local -a mbegin mend match
if [ -n "$selected" ]; then
if [[ $(__fzf_exec_awk '{print $1; exit}' <<< "$selected") =~ ^[1-9][0-9]* ]]; then
zle vi-fetch-history -n $MATCH
# Heuristic to check if the selected value is from history or a custom query
if ((( extracted_with_perl )) && [[ $selected == <->$'\t'* ]]) ||
((( ! extracted_with_perl )) && [[ $selected == [[:blank:]]#<->( |\* )* ]]); then
# Split at newlines
for line in ${(ps:\n:)selected}; do
if (( extracted_with_perl )); then
if [[ $line == (#b)(<->)(#B)$'\t'* ]]; then
(( ${+history[${match[1]}]} )) && cmds+=("${history[${match[1]}]}")
fi
elif [[ $line == [[:blank:]]#(#b)(<->)(#B)( |\* )* ]]; then
# Avoid $history array: lags behind 'fc' on foreign commands (*)
# https://zsh.org/mla/users/2024/msg00692.html
# Push BUFFER onto stack; fetch and save history entry from BUFFER; restore
zle .push-line
zle vi-fetch-history -n ${match[1]}
(( ${#BUFFER} )) && cmds+=("${BUFFER}")
BUFFER=""
zle .get-line
fi
done
if (( ${#cmds[@]} )); then
# Join by newline after stripping trailing newlines from each command
BUFFER="${(pj:\n:)${(@)cmds%%$'\n'#}}"
CURSOR=${#BUFFER}
fi
else # selected is a custom query, not from history
LBUFFER="$selected"
fi
+1 -1
View File
@@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2013-2025 Junegunn Choi
Copyright (c) 2013-2026 Junegunn Choi
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+158 -150
View File
@@ -30,159 +30,167 @@ func _() {
_ = x[actChangeBorderLabel-19]
_ = x[actChangeGhost-20]
_ = x[actChangeHeader-21]
_ = x[actChangeFooter-22]
_ = x[actChangeHeaderLabel-23]
_ = x[actChangeFooterLabel-24]
_ = x[actChangeInputLabel-25]
_ = x[actChangeListLabel-26]
_ = x[actChangeMulti-27]
_ = x[actChangeNth-28]
_ = x[actChangePointer-29]
_ = x[actChangePreview-30]
_ = x[actChangePreviewLabel-31]
_ = x[actChangePreviewWindow-32]
_ = x[actChangePrompt-33]
_ = x[actChangeQuery-34]
_ = x[actClearScreen-35]
_ = x[actClearQuery-36]
_ = x[actClearSelection-37]
_ = x[actClose-38]
_ = x[actDeleteChar-39]
_ = x[actDeleteCharEof-40]
_ = x[actEndOfLine-41]
_ = x[actFatal-42]
_ = x[actForwardChar-43]
_ = x[actForwardWord-44]
_ = x[actForwardSubWord-45]
_ = x[actKillLine-46]
_ = x[actKillWord-47]
_ = x[actKillSubWord-48]
_ = x[actUnixLineDiscard-49]
_ = x[actUnixWordRubout-50]
_ = x[actYank-51]
_ = x[actBackwardKillWord-52]
_ = x[actBackwardKillSubWord-53]
_ = x[actSelectAll-54]
_ = x[actDeselectAll-55]
_ = x[actToggle-56]
_ = x[actToggleSearch-57]
_ = x[actToggleAll-58]
_ = x[actToggleDown-59]
_ = x[actToggleUp-60]
_ = x[actToggleIn-61]
_ = x[actToggleOut-62]
_ = x[actToggleTrack-63]
_ = x[actToggleTrackCurrent-64]
_ = x[actToggleHeader-65]
_ = x[actToggleWrap-66]
_ = x[actToggleMultiLine-67]
_ = x[actToggleHscroll-68]
_ = x[actToggleRaw-69]
_ = x[actEnableRaw-70]
_ = x[actDisableRaw-71]
_ = x[actTrackCurrent-72]
_ = x[actToggleInput-73]
_ = x[actHideInput-74]
_ = x[actShowInput-75]
_ = x[actUntrackCurrent-76]
_ = x[actDown-77]
_ = x[actDownMatch-78]
_ = x[actUp-79]
_ = x[actUpMatch-80]
_ = x[actPageUp-81]
_ = x[actPageDown-82]
_ = x[actPosition-83]
_ = x[actHalfPageUp-84]
_ = x[actHalfPageDown-85]
_ = x[actOffsetUp-86]
_ = x[actOffsetDown-87]
_ = x[actOffsetMiddle-88]
_ = x[actJump-89]
_ = x[actJumpAccept-90]
_ = x[actPrintQuery-91]
_ = x[actRefreshPreview-92]
_ = x[actReplaceQuery-93]
_ = x[actToggleSort-94]
_ = x[actShowPreview-95]
_ = x[actHidePreview-96]
_ = x[actTogglePreview-97]
_ = x[actTogglePreviewWrap-98]
_ = x[actTransform-99]
_ = x[actTransformBorderLabel-100]
_ = x[actTransformGhost-101]
_ = x[actTransformHeader-102]
_ = x[actTransformFooter-103]
_ = x[actTransformHeaderLabel-104]
_ = x[actTransformFooterLabel-105]
_ = x[actTransformInputLabel-106]
_ = x[actTransformListLabel-107]
_ = x[actTransformNth-108]
_ = x[actTransformPointer-109]
_ = x[actTransformPreviewLabel-110]
_ = x[actTransformPrompt-111]
_ = x[actTransformQuery-112]
_ = x[actTransformSearch-113]
_ = x[actTrigger-114]
_ = x[actBgTransform-115]
_ = x[actBgTransformBorderLabel-116]
_ = x[actBgTransformGhost-117]
_ = x[actBgTransformHeader-118]
_ = x[actBgTransformFooter-119]
_ = x[actBgTransformHeaderLabel-120]
_ = x[actBgTransformFooterLabel-121]
_ = x[actBgTransformInputLabel-122]
_ = x[actBgTransformListLabel-123]
_ = x[actBgTransformNth-124]
_ = x[actBgTransformPointer-125]
_ = x[actBgTransformPreviewLabel-126]
_ = x[actBgTransformPrompt-127]
_ = x[actBgTransformQuery-128]
_ = x[actBgTransformSearch-129]
_ = x[actBgCancel-130]
_ = x[actSearch-131]
_ = x[actPreview-132]
_ = x[actPreviewTop-133]
_ = x[actPreviewBottom-134]
_ = x[actPreviewUp-135]
_ = x[actPreviewDown-136]
_ = x[actPreviewPageUp-137]
_ = x[actPreviewPageDown-138]
_ = x[actPreviewHalfPageUp-139]
_ = x[actPreviewHalfPageDown-140]
_ = x[actPrevHistory-141]
_ = x[actPrevSelected-142]
_ = x[actPrint-143]
_ = x[actPut-144]
_ = x[actNextHistory-145]
_ = x[actNextSelected-146]
_ = x[actExecute-147]
_ = x[actExecuteSilent-148]
_ = x[actExecuteMulti-149]
_ = x[actSigStop-150]
_ = x[actBest-151]
_ = x[actFirst-152]
_ = x[actLast-153]
_ = x[actReload-154]
_ = x[actReloadSync-155]
_ = x[actDisableSearch-156]
_ = x[actEnableSearch-157]
_ = x[actSelect-158]
_ = x[actDeselect-159]
_ = x[actUnbind-160]
_ = x[actRebind-161]
_ = x[actToggleBind-162]
_ = x[actBecome-163]
_ = x[actShowHeader-164]
_ = x[actHideHeader-165]
_ = x[actBell-166]
_ = x[actExclude-167]
_ = x[actExcludeMulti-168]
_ = x[actAsync-169]
_ = x[actChangeHeaderLines-22]
_ = x[actChangeFooter-23]
_ = x[actChangeHeaderLabel-24]
_ = x[actChangeFooterLabel-25]
_ = x[actChangeInputLabel-26]
_ = x[actChangeListLabel-27]
_ = x[actChangeMulti-28]
_ = x[actChangeNth-29]
_ = x[actChangeWithNth-30]
_ = x[actChangePointer-31]
_ = x[actChangePreview-32]
_ = x[actChangePreviewLabel-33]
_ = x[actChangePreviewWindow-34]
_ = x[actChangePrompt-35]
_ = x[actChangeQuery-36]
_ = x[actClearScreen-37]
_ = x[actClearQuery-38]
_ = x[actClearSelection-39]
_ = x[actClose-40]
_ = x[actDeleteChar-41]
_ = x[actDeleteCharEof-42]
_ = x[actEndOfLine-43]
_ = x[actFatal-44]
_ = x[actForwardChar-45]
_ = x[actForwardWord-46]
_ = x[actForwardSubWord-47]
_ = x[actKillLine-48]
_ = x[actKillWord-49]
_ = x[actKillSubWord-50]
_ = x[actUnixLineDiscard-51]
_ = x[actUnixWordRubout-52]
_ = x[actYank-53]
_ = x[actBackwardKillWord-54]
_ = x[actBackwardKillSubWord-55]
_ = x[actSelectAll-56]
_ = x[actDeselectAll-57]
_ = x[actToggle-58]
_ = x[actToggleSearch-59]
_ = x[actToggleAll-60]
_ = x[actToggleDown-61]
_ = x[actToggleUp-62]
_ = x[actToggleIn-63]
_ = x[actToggleOut-64]
_ = x[actToggleTrack-65]
_ = x[actToggleTrackCurrent-66]
_ = x[actToggleHeader-67]
_ = x[actToggleWrap-68]
_ = x[actToggleWrapWord-69]
_ = x[actToggleMultiLine-70]
_ = x[actToggleHscroll-71]
_ = x[actToggleRaw-72]
_ = x[actEnableRaw-73]
_ = x[actDisableRaw-74]
_ = x[actTrackCurrent-75]
_ = x[actToggleInput-76]
_ = x[actHideInput-77]
_ = x[actShowInput-78]
_ = x[actUntrackCurrent-79]
_ = x[actDown-80]
_ = x[actDownMatch-81]
_ = x[actUp-82]
_ = x[actUpMatch-83]
_ = x[actPageUp-84]
_ = x[actPageDown-85]
_ = x[actPosition-86]
_ = x[actHalfPageUp-87]
_ = x[actHalfPageDown-88]
_ = x[actOffsetUp-89]
_ = x[actOffsetDown-90]
_ = x[actOffsetMiddle-91]
_ = x[actJump-92]
_ = x[actJumpAccept-93]
_ = x[actPrintQuery-94]
_ = x[actRefreshPreview-95]
_ = x[actReplaceQuery-96]
_ = x[actToggleSort-97]
_ = x[actShowPreview-98]
_ = x[actHidePreview-99]
_ = x[actTogglePreview-100]
_ = x[actTogglePreviewWrap-101]
_ = x[actTogglePreviewWrapWord-102]
_ = x[actTransform-103]
_ = x[actTransformBorderLabel-104]
_ = x[actTransformGhost-105]
_ = x[actTransformHeader-106]
_ = x[actTransformHeaderLines-107]
_ = x[actTransformFooter-108]
_ = x[actTransformHeaderLabel-109]
_ = x[actTransformFooterLabel-110]
_ = x[actTransformInputLabel-111]
_ = x[actTransformListLabel-112]
_ = x[actTransformNth-113]
_ = x[actTransformWithNth-114]
_ = x[actTransformPointer-115]
_ = x[actTransformPreviewLabel-116]
_ = x[actTransformPrompt-117]
_ = x[actTransformQuery-118]
_ = x[actTransformSearch-119]
_ = x[actTrigger-120]
_ = x[actBgTransform-121]
_ = x[actBgTransformBorderLabel-122]
_ = x[actBgTransformGhost-123]
_ = x[actBgTransformHeader-124]
_ = x[actBgTransformHeaderLines-125]
_ = x[actBgTransformFooter-126]
_ = x[actBgTransformHeaderLabel-127]
_ = x[actBgTransformFooterLabel-128]
_ = x[actBgTransformInputLabel-129]
_ = x[actBgTransformListLabel-130]
_ = x[actBgTransformNth-131]
_ = x[actBgTransformWithNth-132]
_ = x[actBgTransformPointer-133]
_ = x[actBgTransformPreviewLabel-134]
_ = x[actBgTransformPrompt-135]
_ = x[actBgTransformQuery-136]
_ = x[actBgTransformSearch-137]
_ = x[actBgCancel-138]
_ = x[actSearch-139]
_ = x[actPreview-140]
_ = x[actPreviewTop-141]
_ = x[actPreviewBottom-142]
_ = x[actPreviewUp-143]
_ = x[actPreviewDown-144]
_ = x[actPreviewPageUp-145]
_ = x[actPreviewPageDown-146]
_ = x[actPreviewHalfPageUp-147]
_ = x[actPreviewHalfPageDown-148]
_ = x[actPrevHistory-149]
_ = x[actPrevSelected-150]
_ = x[actPrint-151]
_ = x[actPut-152]
_ = x[actNextHistory-153]
_ = x[actNextSelected-154]
_ = x[actExecute-155]
_ = x[actExecuteSilent-156]
_ = x[actExecuteMulti-157]
_ = x[actSigStop-158]
_ = x[actBest-159]
_ = x[actFirst-160]
_ = x[actLast-161]
_ = x[actReload-162]
_ = x[actReloadSync-163]
_ = x[actDisableSearch-164]
_ = x[actEnableSearch-165]
_ = x[actSelect-166]
_ = x[actDeselect-167]
_ = x[actUnbind-168]
_ = x[actRebind-169]
_ = x[actToggleBind-170]
_ = x[actBecome-171]
_ = x[actShowHeader-172]
_ = x[actHideHeader-173]
_ = x[actBell-174]
_ = x[actExclude-175]
_ = x[actExcludeMulti-176]
_ = x[actAsync-177]
}
const _actionType_name = "actIgnoreactStartactClickactInvalidactBracketedPasteBeginactBracketedPasteEndactCharactMouseactBeginningOfLineactAbortactAcceptactAcceptNonEmptyactAcceptOrPrintQueryactBackwardCharactBackwardDeleteCharactBackwardDeleteCharEofactBackwardWordactBackwardSubWordactCancelactChangeBorderLabelactChangeGhostactChangeHeaderactChangeFooteractChangeHeaderLabelactChangeFooterLabelactChangeInputLabelactChangeListLabelactChangeMultiactChangeNthactChangePointeractChangePreviewactChangePreviewLabelactChangePreviewWindowactChangePromptactChangeQueryactClearScreenactClearQueryactClearSelectionactCloseactDeleteCharactDeleteCharEofactEndOfLineactFatalactForwardCharactForwardWordactForwardSubWordactKillLineactKillWordactKillSubWordactUnixLineDiscardactUnixWordRuboutactYankactBackwardKillWordactBackwardKillSubWordactSelectAllactDeselectAllactToggleactToggleSearchactToggleAllactToggleDownactToggleUpactToggleInactToggleOutactToggleTrackactToggleTrackCurrentactToggleHeaderactToggleWrapactToggleMultiLineactToggleHscrollactToggleRawactEnableRawactDisableRawactTrackCurrentactToggleInputactHideInputactShowInputactUntrackCurrentactDownactDownMatchactUpactUpMatchactPageUpactPageDownactPositionactHalfPageUpactHalfPageDownactOffsetUpactOffsetDownactOffsetMiddleactJumpactJumpAcceptactPrintQueryactRefreshPreviewactReplaceQueryactToggleSortactShowPreviewactHidePreviewactTogglePreviewactTogglePreviewWrapactTransformactTransformBorderLabelactTransformGhostactTransformHeaderactTransformFooteractTransformHeaderLabelactTransformFooterLabelactTransformInputLabelactTransformListLabelactTransformNthactTransformPointeractTransformPreviewLabelactTransformPromptactTransformQueryactTransformSearchactTriggeractBgTransformactBgTransformBorderLabelactBgTransformGhostactBgTransformHeaderactBgTransformFooteractBgTransformHeaderLabelactBgTransformFooterLabelactBgTransformInputLabelactBgTransformListLabelactBgTransformNthactBgTransformPointeractBgTransformPreviewLabelactBgTransformPromptactBgTransformQueryactBgTransformSearchactBgCancelactSearchactPreviewactPreviewTopactPreviewBottomactPreviewUpactPreviewDownactPreviewPageUpactPreviewPageDownactPreviewHalfPageUpactPreviewHalfPageDownactPrevHistoryactPrevSelectedactPrintactPutactNextHistoryactNextSelectedactExecuteactExecuteSilentactExecuteMultiactSigStopactBestactFirstactLastactReloadactReloadSyncactDisableSearchactEnableSearchactSelectactDeselectactUnbindactRebindactToggleBindactBecomeactShowHeaderactHideHeaderactBellactExcludeactExcludeMultiactAsync"
const _actionType_name = "actIgnoreactStartactClickactInvalidactBracketedPasteBeginactBracketedPasteEndactCharactMouseactBeginningOfLineactAbortactAcceptactAcceptNonEmptyactAcceptOrPrintQueryactBackwardCharactBackwardDeleteCharactBackwardDeleteCharEofactBackwardWordactBackwardSubWordactCancelactChangeBorderLabelactChangeGhostactChangeHeaderactChangeHeaderLinesactChangeFooteractChangeHeaderLabelactChangeFooterLabelactChangeInputLabelactChangeListLabelactChangeMultiactChangeNthactChangeWithNthactChangePointeractChangePreviewactChangePreviewLabelactChangePreviewWindowactChangePromptactChangeQueryactClearScreenactClearQueryactClearSelectionactCloseactDeleteCharactDeleteCharEofactEndOfLineactFatalactForwardCharactForwardWordactForwardSubWordactKillLineactKillWordactKillSubWordactUnixLineDiscardactUnixWordRuboutactYankactBackwardKillWordactBackwardKillSubWordactSelectAllactDeselectAllactToggleactToggleSearchactToggleAllactToggleDownactToggleUpactToggleInactToggleOutactToggleTrackactToggleTrackCurrentactToggleHeaderactToggleWrapactToggleWrapWordactToggleMultiLineactToggleHscrollactToggleRawactEnableRawactDisableRawactTrackCurrentactToggleInputactHideInputactShowInputactUntrackCurrentactDownactDownMatchactUpactUpMatchactPageUpactPageDownactPositionactHalfPageUpactHalfPageDownactOffsetUpactOffsetDownactOffsetMiddleactJumpactJumpAcceptactPrintQueryactRefreshPreviewactReplaceQueryactToggleSortactShowPreviewactHidePreviewactTogglePreviewactTogglePreviewWrapactTogglePreviewWrapWordactTransformactTransformBorderLabelactTransformGhostactTransformHeaderactTransformHeaderLinesactTransformFooteractTransformHeaderLabelactTransformFooterLabelactTransformInputLabelactTransformListLabelactTransformNthactTransformWithNthactTransformPointeractTransformPreviewLabelactTransformPromptactTransformQueryactTransformSearchactTriggeractBgTransformactBgTransformBorderLabelactBgTransformGhostactBgTransformHeaderactBgTransformHeaderLinesactBgTransformFooteractBgTransformHeaderLabelactBgTransformFooterLabelactBgTransformInputLabelactBgTransformListLabelactBgTransformNthactBgTransformWithNthactBgTransformPointeractBgTransformPreviewLabelactBgTransformPromptactBgTransformQueryactBgTransformSearchactBgCancelactSearchactPreviewactPreviewTopactPreviewBottomactPreviewUpactPreviewDownactPreviewPageUpactPreviewPageDownactPreviewHalfPageUpactPreviewHalfPageDownactPrevHistoryactPrevSelectedactPrintactPutactNextHistoryactNextSelectedactExecuteactExecuteSilentactExecuteMultiactSigStopactBestactFirstactLastactReloadactReloadSyncactDisableSearchactEnableSearchactSelectactDeselectactUnbindactRebindactToggleBindactBecomeactShowHeaderactHideHeaderactBellactExcludeactExcludeMultiactAsync"
var _actionType_index = [...]uint16{0, 9, 17, 25, 35, 57, 77, 84, 92, 110, 118, 127, 144, 165, 180, 201, 225, 240, 258, 267, 287, 301, 316, 331, 351, 371, 390, 408, 422, 434, 450, 466, 487, 509, 524, 538, 552, 565, 582, 590, 603, 619, 631, 639, 653, 667, 684, 695, 706, 720, 738, 755, 762, 781, 803, 815, 829, 838, 853, 865, 878, 889, 900, 912, 926, 947, 962, 975, 993, 1009, 1021, 1033, 1046, 1061, 1075, 1087, 1099, 1116, 1123, 1135, 1140, 1150, 1159, 1170, 1181, 1194, 1209, 1220, 1233, 1248, 1255, 1268, 1281, 1298, 1313, 1326, 1340, 1354, 1370, 1390, 1402, 1425, 1442, 1460, 1478, 1501, 1524, 1546, 1567, 1582, 1601, 1625, 1643, 1660, 1678, 1688, 1702, 1727, 1746, 1766, 1786, 1811, 1836, 1860, 1883, 1900, 1921, 1947, 1967, 1986, 2006, 2017, 2026, 2036, 2049, 2065, 2077, 2091, 2107, 2125, 2145, 2167, 2181, 2196, 2204, 2210, 2224, 2239, 2249, 2265, 2280, 2290, 2297, 2305, 2312, 2321, 2334, 2350, 2365, 2374, 2385, 2394, 2403, 2416, 2425, 2438, 2451, 2458, 2468, 2483, 2491}
var _actionType_index = [...]uint16{0, 9, 17, 25, 35, 57, 77, 84, 92, 110, 118, 127, 144, 165, 180, 201, 225, 240, 258, 267, 287, 301, 316, 336, 351, 371, 391, 410, 428, 442, 454, 470, 486, 502, 523, 545, 560, 574, 588, 601, 618, 626, 639, 655, 667, 675, 689, 703, 720, 731, 742, 756, 774, 791, 798, 817, 839, 851, 865, 874, 889, 901, 914, 925, 936, 948, 962, 983, 998, 1011, 1028, 1046, 1062, 1074, 1086, 1099, 1114, 1128, 1140, 1152, 1169, 1176, 1188, 1193, 1203, 1212, 1223, 1234, 1247, 1262, 1273, 1286, 1301, 1308, 1321, 1334, 1351, 1366, 1379, 1393, 1407, 1423, 1443, 1467, 1479, 1502, 1519, 1537, 1560, 1578, 1601, 1624, 1646, 1667, 1682, 1701, 1720, 1744, 1762, 1779, 1797, 1807, 1821, 1846, 1865, 1885, 1910, 1930, 1955, 1980, 2004, 2027, 2044, 2065, 2086, 2112, 2132, 2151, 2171, 2182, 2191, 2201, 2214, 2230, 2242, 2256, 2272, 2290, 2310, 2332, 2346, 2361, 2369, 2375, 2389, 2404, 2414, 2430, 2445, 2455, 2462, 2470, 2477, 2486, 2499, 2515, 2530, 2539, 2550, 2559, 2568, 2581, 2590, 2603, 2616, 2623, 2633, 2648, 2656}
func (i actionType) String() string {
if i < 0 || i >= actionType(len(_actionType_index)-1) {
+99
View File
@@ -0,0 +1,99 @@
# SIMD byte search: `indexByteTwo` / `lastIndexByteTwo`
## What these functions do
`indexByteTwo(s []byte, b1, b2 byte) int` -- returns the index of the
**first** occurrence of `b1` or `b2` in `s`, or `-1`.
`lastIndexByteTwo(s []byte, b1, b2 byte) int` -- returns the index of the
**last** occurrence of `b1` or `b2` in `s`, or `-1`.
They are used by the fuzzy matching algorithm (`algo.go`) to skip ahead
during case-insensitive search. Instead of calling `bytes.IndexByte` twice
(once for lowercase, once for uppercase), a single SIMD pass finds both at
once.
## File layout
| File | Purpose |
| ------ | --------- |
| `indexbyte2_arm64.go` | Go declarations (`//go:noescape`) for ARM64 |
| `indexbyte2_arm64.s` | ARM64 NEON assembly (32-byte aligned blocks, syndrome extraction) |
| `indexbyte2_amd64.go` | Go declarations + AVX2 runtime detection for AMD64 |
| `indexbyte2_amd64.s` | AMD64 AVX2/SSE2 assembly with CPUID dispatch |
| `indexbyte2_other.go` | Pure Go fallback for all other architectures |
| `indexbyte2_test.go` | Unit tests, exhaustive tests, fuzz tests, and benchmarks |
## How the SIMD implementations work
**ARM64 (NEON):**
- Broadcasts both needle bytes into NEON registers (`VMOV`).
- Processes 32-byte aligned chunks. For each chunk, compares all bytes
against both needles (`VCMEQ`), ORs the results (`VORR`), and builds a
64-bit syndrome with 2 bits per byte.
- `indexByteTwo` uses `RBIT` + `CLZ` to find the lowest set bit (first match).
- `lastIndexByteTwo` scans backward and uses `CLZ` on the raw syndrome to
find the highest set bit (last match).
- Handles alignment and partial first/last blocks with bit masking.
- Adapted from Go's `internal/bytealg/indexbyte_arm64.s`.
**AMD64 (AVX2 with SSE2 fallback):**
- At init time, `cpuHasAVX2()` checks CPUID + XGETBV for AVX2 and OS YMM
support. The result is cached in `_useAVX2`.
- **AVX2 path** (inputs >= 32 bytes, when available):
- Broadcasts both needles via `VPBROADCASTB`.
- Processes 32-byte blocks: `VPCMPEQB` against both needles, `VPOR`, then
`VPMOVMSKB` to get a 32-bit mask.
- 5 instructions per loop iteration (vs 7 for SSE2) at 2x the throughput.
- `VZEROUPPER` before every return to avoid SSE/AVX transition penalties.
- **SSE2 fallback** (inputs < 32 bytes, or CPUs without AVX2):
- Broadcasts via `PUNPCKLBW` + `PSHUFL`.
- Processes 16-byte blocks: `PCMPEQB`, `POR`, `PMOVMSKB`.
- Small inputs (<16 bytes) are handled with page-boundary-safe loads.
- Both paths use `BSFL` (forward) / `BSRL` (reverse) for bit scanning.
- Adapted from Go's `internal/bytealg/indexbyte_amd64.s`.
**Fallback (other platforms):**
- `indexByteTwo` uses two `bytes.IndexByte` calls with scope-limiting
(search `b1` first, then limit the `b2` search to `s[:i1]`).
- `lastIndexByteTwo` uses a simple backward for loop.
## Running tests
```bash
# Unit + exhaustive tests
go test ./src/algo/ -run 'TestIndexByteTwo|TestLastIndexByteTwo' -v
# Fuzz tests (run for 10 seconds each)
go test ./src/algo/ -run '^$' -fuzz FuzzIndexByteTwo -fuzztime 10s
go test ./src/algo/ -run '^$' -fuzz FuzzLastIndexByteTwo -fuzztime 10s
# Cross-architecture: test amd64 on an arm64 Mac (via Rosetta)
GOARCH=amd64 go test ./src/algo/ -run 'TestIndexByteTwo|TestLastIndexByteTwo' -v
GOARCH=amd64 go test ./src/algo/ -run '^$' -fuzz FuzzIndexByteTwo -fuzztime 10s
GOARCH=amd64 go test ./src/algo/ -run '^$' -fuzz FuzzLastIndexByteTwo -fuzztime 10s
```
## Running micro-benchmarks
```bash
# All indexByteTwo / lastIndexByteTwo benchmarks
go test ./src/algo/ -bench 'IndexByteTwo' -benchmem
# Specific size
go test ./src/algo/ -bench 'IndexByteTwo_1000'
```
Each benchmark compares the SIMD `asm` implementation against reference
implementations (`2xIndexByte` using `bytes.IndexByte`, and a simple `loop`).
## Correctness verification
The assembly is verified by three layers of testing:
1. **Table-driven tests** -- known inputs with expected outputs.
2. **Exhaustive tests** -- all lengths 0256, every match position, no-match
cases, and both-bytes-present cases, compared against a simple loop
reference.
3. **Fuzz tests** -- randomized inputs via `testing.F`, compared against the
same loop reference.
+28 -30
View File
@@ -266,7 +266,7 @@ func charClassOf(char rune) charClass {
}
func bonusFor(prevClass charClass, class charClass) int16 {
if class > charNonWord {
if class >= charNonWord {
switch prevClass {
case charWhite:
// Word boundary after whitespace
@@ -321,22 +321,15 @@ type Algo func(caseSensitive bool, normalize bool, forward bool, input *util.Cha
func trySkip(input *util.Chars, caseSensitive bool, b byte, from int) int {
byteArray := input.Bytes()[from:]
idx := bytes.IndexByte(byteArray, b)
if idx == 0 {
// Can't skip any further
return from
}
// We may need to search for the uppercase letter again. We don't have to
// consider normalization as we can be sure that this is an ASCII string.
// For case-insensitive search of a letter, search for both cases in one pass
if !caseSensitive && b >= 'a' && b <= 'z' {
if idx > 0 {
byteArray = byteArray[:idx]
}
uidx := bytes.IndexByte(byteArray, b-32)
if uidx >= 0 {
idx = uidx
idx := IndexByteTwo(byteArray, b, b-32)
if idx < 0 {
return -1
}
return from + idx
}
idx := bytes.IndexByte(byteArray, b)
if idx < 0 {
return -1
}
@@ -365,7 +358,7 @@ func asciiFuzzyIndex(input *util.Chars, pattern []rune, caseSensitive bool) (int
firstIdx, idx, lastIdx := 0, 0, 0
var b byte
for pidx := 0; pidx < len(pattern); pidx++ {
for pidx := range pattern {
b = byte(pattern[pidx])
idx = trySkip(input, caseSensitive, b, idx)
if idx < 0 {
@@ -380,14 +373,17 @@ func asciiFuzzyIndex(input *util.Chars, pattern []rune, caseSensitive bool) (int
}
// Find the last appearance of the last character of the pattern to limit the search scope
bu := b
if !caseSensitive && b >= 'a' && b <= 'z' {
bu = b - 32
}
scope := input.Bytes()[lastIdx:]
for offset := len(scope) - 1; offset > 0; offset-- {
if scope[offset] == b || scope[offset] == bu {
return firstIdx, lastIdx + offset + 1
if len(scope) > 1 {
tail := scope[1:]
var end int
if !caseSensitive && b >= 'a' && b <= 'z' {
end = lastIndexByteTwo(tail, b, b-32)
} else {
end = bytes.LastIndexByte(tail, b)
}
if end >= 0 {
return firstIdx, lastIdx + 1 + end + 1
}
}
return firstIdx, lastIdx + 1
@@ -445,7 +441,9 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
// Since O(nm) algorithm can be prohibitively expensive for large input,
// we fall back to the greedy algorithm.
if slab != nil && N*M > cap(slab.I16) {
// Also, we should not allow a very long pattern to avoid 16-bit integer
// overflow in the score matrix. 1000 is a safe limit.
if slab != nil && int64(N)*int64(M) > int64(cap(slab.I16)) || M > 1000 {
return FuzzyMatchV1(caseSensitive, normalize, forward, input, pattern, withPos, slab)
}
@@ -501,7 +499,7 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
if pidx < M {
F[pidx] = int32(off)
pidx++
pchar = pattern[util.Min(pidx, M-1)]
pchar = pattern[min(pidx, M-1)]
}
lastIdx = off
}
@@ -519,9 +517,9 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
inGap = false
} else {
if inGap {
H0[off] = util.Max16(prevH0+scoreGapExtension, 0)
H0[off] = max(prevH0+scoreGapExtension, 0)
} else {
H0[off] = util.Max16(prevH0+scoreGapStart, 0)
H0[off] = max(prevH0+scoreGapStart, 0)
}
C0[off] = 0
inGap = true
@@ -587,7 +585,7 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
if b >= bonusBoundary && b > fb {
consecutive = 1
} else {
b = util.Max16(b, util.Max16(bonusConsecutive, fb))
b = max(b, bonusConsecutive, fb)
}
}
if s1+b < s2 {
@@ -600,7 +598,7 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
Csub[off] = consecutive
inGap = s1 < s2
score := util.Max16(util.Max16(s1, s2), 0)
score := max(s1, s2, 0)
if pidx == M-1 && (forward && score > maxScore || !forward && score >= maxScore) {
maxScore, maxScorePos = score, col
}
@@ -684,7 +682,7 @@ func calculateScore(caseSensitive bool, normalize bool, text *util.Chars, patter
if bonus >= bonusBoundary && bonus > firstBonus {
firstBonus = bonus
}
bonus = util.Max16(util.Max16(bonus, firstBonus), bonusConsecutive)
bonus = max(bonus, firstBonus, bonusConsecutive)
}
if pidx == 0 {
score += int(bonus * bonusFirstCharMultiplier)
@@ -726,7 +724,7 @@ func FuzzyMatchV1(caseSensitive bool, normalize bool, forward bool, text *util.C
lenRunes := text.Length()
lenPattern := len(pattern)
for index := 0; index < lenRunes; index++ {
for index := range lenRunes {
char := text.Get(indexAt(index, lenRunes, forward))
// This is considerably faster than blindly applying strings.ToLower to the
// whole string
+10 -1
View File
@@ -57,6 +57,15 @@ func TestFuzzyMatch(t *testing.T) {
scoreMatch*4+int(bonusBoundaryDelimiter)*bonusFirstCharMultiplier+int(bonusBoundaryDelimiter)*3)
assertMatch(t, fn, false, forward, "/.oh-my-zsh/cache", "zshc", 8, 13,
scoreMatch*4+bonusBoundary*bonusFirstCharMultiplier+bonusBoundary*2+scoreGapStart+int(bonusBoundaryDelimiter))
// Non-word character at start of input is treated as a strong boundary
assertMatch(t, fn, false, forward, ".vimrc", ".vimrc", 0, 6,
scoreMatch*6+int(bonusBoundaryWhite)*(bonusFirstCharMultiplier+5))
// Non-word character right after a delimiter inherits the delimiter boundary
assertMatch(t, fn, false, forward, "/.vimrc", ".vimrc", 1, 7,
scoreMatch*6+int(bonusBoundaryDelimiter)*(bonusFirstCharMultiplier+5))
// Non-word character in the middle of a word stays at bonusNonWord
assertMatch(t, fn, false, forward, "a.vimrc", ".vimrc", 1, 7,
scoreMatch*6+bonusBoundary*(bonusFirstCharMultiplier+5))
assertMatch(t, fn, false, forward, "ab0123 456", "12356", 3, 10,
scoreMatch*5+bonusConsecutive*3+scoreGapStart+scoreGapExtension)
assertMatch(t, fn, false, forward, "abc123 456", "12356", 3, 10,
@@ -86,7 +95,7 @@ func TestFuzzyMatch(t *testing.T) {
scoreGapStart*2+scoreGapExtension*2)
assertMatch(t, fn, true, forward, "FooBar Baz", "FooB", 0, 4,
scoreMatch*4+int(bonusBoundaryWhite)*bonusFirstCharMultiplier+int(bonusBoundaryWhite)*2+
util.Max(bonusCamel123, int(bonusBoundaryWhite)))
max(bonusCamel123, int(bonusBoundaryWhite)))
// Consecutive bonus updated
assertMatch(t, fn, true, forward, "foo-bar", "o-ba", 2, 6,
+24
View File
@@ -0,0 +1,24 @@
//go:build amd64
package algo
var _useAVX2 bool
func init() {
_useAVX2 = cpuHasAVX2()
}
//go:noescape
func cpuHasAVX2() bool
// indexByteTwo returns the index of the first occurrence of b1 or b2 in s,
// or -1 if neither is present. Uses AVX2 when available, SSE2 otherwise.
//
//go:noescape
func IndexByteTwo(s []byte, b1, b2 byte) int
// lastIndexByteTwo returns the index of the last occurrence of b1 or b2 in s,
// or -1 if neither is present. Uses AVX2 when available, SSE2 otherwise.
//
//go:noescape
func lastIndexByteTwo(s []byte, b1, b2 byte) int
+377
View File
@@ -0,0 +1,377 @@
#include "textflag.h"
// func cpuHasAVX2() bool
//
// Checks CPUID and XGETBV for AVX2 + OS YMM support.
TEXT ·cpuHasAVX2(SB),NOSPLIT,$0-1
MOVQ BX, R8 // save BX (callee-saved, clobbered by CPUID)
// Check max CPUID leaf >= 7
MOVL $0, AX
CPUID
CMPL AX, $7
JL cpuid_no
// Check OSXSAVE (CPUID.1:ECX bit 27)
MOVL $1, AX
CPUID
TESTL $(1<<27), CX
JZ cpuid_no
// Check AVX2 (CPUID.7.0:EBX bit 5)
MOVL $7, AX
MOVL $0, CX
CPUID
TESTL $(1<<5), BX
JZ cpuid_no
// Check OS YMM state support via XGETBV
MOVL $0, CX
BYTE $0x0F; BYTE $0x01; BYTE $0xD0 // XGETBV → EDX:EAX
ANDL $6, AX // bits 1 (XMM) and 2 (YMM)
CMPL AX, $6
JNE cpuid_no
MOVQ R8, BX // restore BX
MOVB $1, ret+0(FP)
RET
cpuid_no:
MOVQ R8, BX
MOVB $0, ret+0(FP)
RET
// func IndexByteTwo(s []byte, b1, b2 byte) int
//
// Returns the index of the first occurrence of b1 or b2 in s, or -1.
// Uses AVX2 (32 bytes/iter) when available, SSE2 (16 bytes/iter) otherwise.
TEXT ·IndexByteTwo(SB),NOSPLIT,$0-40
MOVQ s_base+0(FP), SI
MOVQ s_len+8(FP), BX
MOVBLZX b1+24(FP), AX
MOVBLZX b2+25(FP), CX
LEAQ ret+32(FP), R8
TESTQ BX, BX
JEQ fwd_failure
// Try AVX2 for inputs >= 32 bytes
CMPQ BX, $32
JLT fwd_sse2
CMPB ·_useAVX2(SB), $1
JNE fwd_sse2
// ====== AVX2 forward search ======
MOVD AX, X0
VPBROADCASTB X0, Y0 // Y0 = splat(b1)
MOVD CX, X1
VPBROADCASTB X1, Y1 // Y1 = splat(b2)
MOVQ SI, DI
LEAQ -32(SI)(BX*1), AX // AX = last valid 32-byte chunk
JMP fwd_avx2_entry
fwd_avx2_loop:
VMOVDQU (DI), Y2
VPCMPEQB Y0, Y2, Y3
VPCMPEQB Y1, Y2, Y4
VPOR Y3, Y4, Y3
VPMOVMSKB Y3, DX
BSFL DX, DX
JNZ fwd_avx2_success
ADDQ $32, DI
fwd_avx2_entry:
CMPQ DI, AX
JB fwd_avx2_loop
// Last 32-byte chunk (may overlap with previous)
MOVQ AX, DI
VMOVDQU (AX), Y2
VPCMPEQB Y0, Y2, Y3
VPCMPEQB Y1, Y2, Y4
VPOR Y3, Y4, Y3
VPMOVMSKB Y3, DX
BSFL DX, DX
JNZ fwd_avx2_success
MOVQ $-1, (R8)
VZEROUPPER
RET
fwd_avx2_success:
SUBQ SI, DI
ADDQ DX, DI
MOVQ DI, (R8)
VZEROUPPER
RET
// ====== SSE2 forward search (< 32 bytes or no AVX2) ======
fwd_sse2:
// Broadcast b1 into X0
MOVD AX, X0
PUNPCKLBW X0, X0
PUNPCKLBW X0, X0
PSHUFL $0, X0, X0
// Broadcast b2 into X4
MOVD CX, X4
PUNPCKLBW X4, X4
PUNPCKLBW X4, X4
PSHUFL $0, X4, X4
CMPQ BX, $16
JLT fwd_small
MOVQ SI, DI
LEAQ -16(SI)(BX*1), AX
JMP fwd_sseloopentry
fwd_sseloop:
MOVOU (DI), X1
MOVOU X1, X2
PCMPEQB X0, X1
PCMPEQB X4, X2
POR X2, X1
PMOVMSKB X1, DX
BSFL DX, DX
JNZ fwd_ssesuccess
ADDQ $16, DI
fwd_sseloopentry:
CMPQ DI, AX
JB fwd_sseloop
// Search the last 16-byte chunk (may overlap)
MOVQ AX, DI
MOVOU (AX), X1
MOVOU X1, X2
PCMPEQB X0, X1
PCMPEQB X4, X2
POR X2, X1
PMOVMSKB X1, DX
BSFL DX, DX
JNZ fwd_ssesuccess
fwd_failure:
MOVQ $-1, (R8)
RET
fwd_ssesuccess:
SUBQ SI, DI
ADDQ DX, DI
MOVQ DI, (R8)
RET
fwd_small:
// Check if loading 16 bytes from SI would cross a page boundary
LEAQ 16(SI), AX
TESTW $0xff0, AX
JEQ fwd_endofpage
MOVOU (SI), X1
MOVOU X1, X2
PCMPEQB X0, X1
PCMPEQB X4, X2
POR X2, X1
PMOVMSKB X1, DX
BSFL DX, DX
JZ fwd_failure
CMPL DX, BX
JAE fwd_failure
MOVQ DX, (R8)
RET
fwd_endofpage:
MOVOU -16(SI)(BX*1), X1
MOVOU X1, X2
PCMPEQB X0, X1
PCMPEQB X4, X2
POR X2, X1
PMOVMSKB X1, DX
MOVL BX, CX
SHLL CX, DX
SHRL $16, DX
BSFL DX, DX
JZ fwd_failure
MOVQ DX, (R8)
RET
// func lastIndexByteTwo(s []byte, b1, b2 byte) int
//
// Returns the index of the last occurrence of b1 or b2 in s, or -1.
// Uses AVX2 (32 bytes/iter) when available, SSE2 (16 bytes/iter) otherwise.
TEXT ·lastIndexByteTwo(SB),NOSPLIT,$0-40
MOVQ s_base+0(FP), SI
MOVQ s_len+8(FP), BX
MOVBLZX b1+24(FP), AX
MOVBLZX b2+25(FP), CX
LEAQ ret+32(FP), R8
TESTQ BX, BX
JEQ back_failure
// Try AVX2 for inputs >= 32 bytes
CMPQ BX, $32
JLT back_sse2
CMPB ·_useAVX2(SB), $1
JNE back_sse2
// ====== AVX2 backward search ======
MOVD AX, X0
VPBROADCASTB X0, Y0
MOVD CX, X1
VPBROADCASTB X1, Y1
// DI = start of last 32-byte chunk
LEAQ -32(SI)(BX*1), DI
back_avx2_loop:
CMPQ DI, SI
JBE back_avx2_first
VMOVDQU (DI), Y2
VPCMPEQB Y0, Y2, Y3
VPCMPEQB Y1, Y2, Y4
VPOR Y3, Y4, Y3
VPMOVMSKB Y3, DX
BSRL DX, DX
JNZ back_avx2_success
SUBQ $32, DI
JMP back_avx2_loop
back_avx2_first:
// First 32 bytes (DI <= SI, load from SI)
VMOVDQU (SI), Y2
VPCMPEQB Y0, Y2, Y3
VPCMPEQB Y1, Y2, Y4
VPOR Y3, Y4, Y3
VPMOVMSKB Y3, DX
BSRL DX, DX
JNZ back_avx2_firstsuccess
MOVQ $-1, (R8)
VZEROUPPER
RET
back_avx2_success:
SUBQ SI, DI
ADDQ DX, DI
MOVQ DI, (R8)
VZEROUPPER
RET
back_avx2_firstsuccess:
MOVQ DX, (R8)
VZEROUPPER
RET
// ====== SSE2 backward search (< 32 bytes or no AVX2) ======
back_sse2:
// Broadcast b1 into X0
MOVD AX, X0
PUNPCKLBW X0, X0
PUNPCKLBW X0, X0
PSHUFL $0, X0, X0
// Broadcast b2 into X4
MOVD CX, X4
PUNPCKLBW X4, X4
PUNPCKLBW X4, X4
PSHUFL $0, X4, X4
CMPQ BX, $16
JLT back_small
// DI = start of last 16-byte chunk
LEAQ -16(SI)(BX*1), DI
back_sseloop:
CMPQ DI, SI
JBE back_ssefirst
MOVOU (DI), X1
MOVOU X1, X2
PCMPEQB X0, X1
PCMPEQB X4, X2
POR X2, X1
PMOVMSKB X1, DX
BSRL DX, DX
JNZ back_ssesuccess
SUBQ $16, DI
JMP back_sseloop
back_ssefirst:
// First 16 bytes (DI <= SI, load from SI)
MOVOU (SI), X1
MOVOU X1, X2
PCMPEQB X0, X1
PCMPEQB X4, X2
POR X2, X1
PMOVMSKB X1, DX
BSRL DX, DX
JNZ back_ssefirstsuccess
back_failure:
MOVQ $-1, (R8)
RET
back_ssesuccess:
SUBQ SI, DI
ADDQ DX, DI
MOVQ DI, (R8)
RET
back_ssefirstsuccess:
// DX = byte offset from base
MOVQ DX, (R8)
RET
back_small:
// Check page boundary
LEAQ 16(SI), AX
TESTW $0xff0, AX
JEQ back_endofpage
MOVOU (SI), X1
MOVOU X1, X2
PCMPEQB X0, X1
PCMPEQB X4, X2
POR X2, X1
PMOVMSKB X1, DX
// Mask to first BX bytes: keep bits 0..BX-1
MOVL $1, AX
MOVL BX, CX
SHLL CX, AX
DECL AX
ANDL AX, DX
BSRL DX, DX
JZ back_failure
MOVQ DX, (R8)
RET
back_endofpage:
// Load 16 bytes ending at base+n
MOVOU -16(SI)(BX*1), X1
MOVOU X1, X2
PCMPEQB X0, X1
PCMPEQB X4, X2
POR X2, X1
PMOVMSKB X1, DX
// Bits correspond to bytes [base+n-16, base+n).
// We want original bytes [0, n), which are bits [16-n, 16).
// Mask: keep bits (16-n) through 15.
MOVL $16, CX
SUBL BX, CX
SHRL CX, DX
SHLL CX, DX
BSRL DX, DX
JZ back_failure
// DX is the bit position in the loaded chunk.
// Original byte index = DX - (16 - n) = DX + n - 16
ADDL BX, DX
SUBL $16, DX
MOVQ DX, (R8)
RET
+17
View File
@@ -0,0 +1,17 @@
//go:build arm64
package algo
// indexByteTwo returns the index of the first occurrence of b1 or b2 in s,
// or -1 if neither is present. Implemented in assembly using ARM64 NEON
// to search for both bytes in a single pass.
//
//go:noescape
func IndexByteTwo(s []byte, b1, b2 byte) int
// lastIndexByteTwo returns the index of the last occurrence of b1 or b2 in s,
// or -1 if neither is present. Implemented in assembly using ARM64 NEON,
// scanning backward.
//
//go:noescape
func lastIndexByteTwo(s []byte, b1, b2 byte) int
+249
View File
@@ -0,0 +1,249 @@
#include "textflag.h"
// func IndexByteTwo(s []byte, b1, b2 byte) int
//
// Returns the index of the first occurrence of b1 or b2 in s, or -1.
// Uses ARM64 NEON to search for both bytes in a single pass over the data.
// Adapted from Go's internal/bytealg/indexbyte_arm64.s (single-byte version).
TEXT ·IndexByteTwo(SB),NOSPLIT,$0-40
MOVD s_base+0(FP), R0
MOVD s_len+8(FP), R2
MOVBU b1+24(FP), R1
MOVBU b2+25(FP), R7
MOVD $ret+32(FP), R8
// Core algorithm:
// For each 32-byte chunk we calculate a 64-bit syndrome value,
// with two bits per byte. We compare against both b1 and b2,
// OR the results, then use the same syndrome extraction as
// Go's IndexByte.
CBZ R2, fail
MOVD R0, R11
// Magic constant 0x40100401 allows us to identify which lane matches.
// Each byte in the group of 4 gets a distinct bit: 1, 4, 16, 64.
MOVD $0x40100401, R5
VMOV R1, V0.B16 // V0 = splat(b1)
VMOV R7, V7.B16 // V7 = splat(b2)
// Work with aligned 32-byte chunks
BIC $0x1f, R0, R3
VMOV R5, V5.S4
ANDS $0x1f, R0, R9
AND $0x1f, R2, R10
BEQ loop
// Input string is not 32-byte aligned. Process the first
// aligned 32-byte block and mask off bytes before our start.
VLD1.P (R3), [V1.B16, V2.B16]
SUB $0x20, R9, R4
ADDS R4, R2, R2
// Compare against both needles
VCMEQ V0.B16, V1.B16, V3.B16 // b1 vs first 16 bytes
VCMEQ V7.B16, V1.B16, V8.B16 // b2 vs first 16 bytes
VORR V8.B16, V3.B16, V3.B16 // combine
VCMEQ V0.B16, V2.B16, V4.B16 // b1 vs second 16 bytes
VCMEQ V7.B16, V2.B16, V9.B16 // b2 vs second 16 bytes
VORR V9.B16, V4.B16, V4.B16 // combine
// Build syndrome
VAND V5.B16, V3.B16, V3.B16
VAND V5.B16, V4.B16, V4.B16
VADDP V4.B16, V3.B16, V6.B16
VADDP V6.B16, V6.B16, V6.B16
VMOV V6.D[0], R6
// Clear the irrelevant lower bits
LSL $1, R9, R4
LSR R4, R6, R6
LSL R4, R6, R6
// The first block can also be the last
BLS masklast
// Have we found something already?
CBNZ R6, tail
loop:
VLD1.P (R3), [V1.B16, V2.B16]
SUBS $0x20, R2, R2
// Compare against both needles, OR results
VCMEQ V0.B16, V1.B16, V3.B16
VCMEQ V7.B16, V1.B16, V8.B16
VORR V8.B16, V3.B16, V3.B16
VCMEQ V0.B16, V2.B16, V4.B16
VCMEQ V7.B16, V2.B16, V9.B16
VORR V9.B16, V4.B16, V4.B16
// If we're out of data we finish regardless of the result
BLS end
// Fast check: OR both halves and check for any match
VORR V4.B16, V3.B16, V6.B16
VADDP V6.D2, V6.D2, V6.D2
VMOV V6.D[0], R6
CBZ R6, loop
end:
// Found something or out of data, build full syndrome
VAND V5.B16, V3.B16, V3.B16
VAND V5.B16, V4.B16, V4.B16
VADDP V4.B16, V3.B16, V6.B16
VADDP V6.B16, V6.B16, V6.B16
VMOV V6.D[0], R6
// Only mask for the last block
BHS tail
masklast:
// Clear irrelevant upper bits
ADD R9, R10, R4
AND $0x1f, R4, R4
SUB $0x20, R4, R4
NEG R4<<1, R4
LSL R4, R6, R6
LSR R4, R6, R6
tail:
CBZ R6, fail
RBIT R6, R6
SUB $0x20, R3, R3
CLZ R6, R6
ADD R6>>1, R3, R0
SUB R11, R0, R0
MOVD R0, (R8)
RET
fail:
MOVD $-1, R0
MOVD R0, (R8)
RET
// func lastIndexByteTwo(s []byte, b1, b2 byte) int
//
// Returns the index of the last occurrence of b1 or b2 in s, or -1.
// Scans backward using ARM64 NEON.
TEXT ·lastIndexByteTwo(SB),NOSPLIT,$0-40
MOVD s_base+0(FP), R0
MOVD s_len+8(FP), R2
MOVBU b1+24(FP), R1
MOVBU b2+25(FP), R7
MOVD $ret+32(FP), R8
CBZ R2, lfail
MOVD R0, R11 // save base
ADD R0, R2, R12 // R12 = end = base + len
MOVD $0x40100401, R5
VMOV R1, V0.B16 // V0 = splat(b1)
VMOV R7, V7.B16 // V7 = splat(b2)
VMOV R5, V5.S4
// Align: find the aligned block containing the last byte
SUB $1, R12, R3
BIC $0x1f, R3, R3 // R3 = start of aligned block containing last byte
// --- Process tail block ---
VLD1 (R3), [V1.B16, V2.B16]
VCMEQ V0.B16, V1.B16, V3.B16
VCMEQ V7.B16, V1.B16, V8.B16
VORR V8.B16, V3.B16, V3.B16
VCMEQ V0.B16, V2.B16, V4.B16
VCMEQ V7.B16, V2.B16, V9.B16
VORR V9.B16, V4.B16, V4.B16
VAND V5.B16, V3.B16, V3.B16
VAND V5.B16, V4.B16, V4.B16
VADDP V4.B16, V3.B16, V6.B16
VADDP V6.B16, V6.B16, V6.B16
VMOV V6.D[0], R6
// Mask upper bits (bytes past end of slice)
// tail_bytes = end - R3 (1..32)
SUB R3, R12, R10 // R10 = tail_bytes
MOVD $64, R4
SUB R10<<1, R4, R4 // R4 = 64 - 2*tail_bytes
LSL R4, R6, R6
LSR R4, R6, R6
// Is this also the head block?
CMP R11, R3 // R3 - R11
BLO lmaskfirst // R3 < base: head+tail in same block
BEQ ltailonly // R3 == base: single aligned block
// R3 > base: more blocks before this one
CBNZ R6, llast
B lbacksetup
ltailonly:
// Single block, already masked upper bits
CBNZ R6, llast
B lfail
lmaskfirst:
// Mask lower bits (bytes before start of slice)
SUB R3, R11, R4 // R4 = base - R3
LSL $1, R4, R4
LSR R4, R6, R6
LSL R4, R6, R6
CBNZ R6, llast
B lfail
lbacksetup:
SUB $0x20, R3
lbackloop:
VLD1 (R3), [V1.B16, V2.B16]
VCMEQ V0.B16, V1.B16, V3.B16
VCMEQ V7.B16, V1.B16, V8.B16
VORR V8.B16, V3.B16, V3.B16
VCMEQ V0.B16, V2.B16, V4.B16
VCMEQ V7.B16, V2.B16, V9.B16
VORR V9.B16, V4.B16, V4.B16
// Quick check: any match in this block?
VORR V4.B16, V3.B16, V6.B16
VADDP V6.D2, V6.D2, V6.D2
VMOV V6.D[0], R6
// Is this a head block? (R3 < base)
CMP R11, R3
BLO lheadblock
// Full block (R3 >= base)
CBNZ R6, lbackfound
// More blocks?
BEQ lfail // R3 == base, no more
SUB $0x20, R3
B lbackloop
lbackfound:
// Build full syndrome
VAND V5.B16, V3.B16, V3.B16
VAND V5.B16, V4.B16, V4.B16
VADDP V4.B16, V3.B16, V6.B16
VADDP V6.B16, V6.B16, V6.B16
VMOV V6.D[0], R6
B llast
lheadblock:
// R3 < base. Build full syndrome if quick check had a match.
CBZ R6, lfail
VAND V5.B16, V3.B16, V3.B16
VAND V5.B16, V4.B16, V4.B16
VADDP V4.B16, V3.B16, V6.B16
VADDP V6.B16, V6.B16, V6.B16
VMOV V6.D[0], R6
// Mask lower bits
SUB R3, R11, R4 // R4 = base - R3
LSL $1, R4, R4
LSR R4, R6, R6
LSL R4, R6, R6
CBZ R6, lfail
llast:
// Find last match: highest set bit in syndrome
// Syndrome has bit 2i set for matching byte i.
// CLZ gives leading zeros; byte_offset = (63 - CLZ) / 2.
CLZ R6, R6
MOVD $63, R4
SUB R6, R4, R6 // R6 = 63 - CLZ = bit position
LSR $1, R6 // R6 = byte offset within block
ADD R3, R6, R0 // R0 = absolute address
SUB R11, R0, R0 // R0 = slice index
MOVD R0, (R8)
RET
lfail:
MOVD $-1, R0
MOVD R0, (R8)
RET
+33
View File
@@ -0,0 +1,33 @@
//go:build !arm64 && !amd64
package algo
import "bytes"
// indexByteTwo returns the index of the first occurrence of b1 or b2 in s,
// or -1 if neither is present.
func IndexByteTwo(s []byte, b1, b2 byte) int {
i1 := bytes.IndexByte(s, b1)
if i1 == 0 {
return 0
}
scope := s
if i1 > 0 {
scope = s[:i1]
}
if i2 := bytes.IndexByte(scope, b2); i2 >= 0 {
return i2
}
return i1
}
// lastIndexByteTwo returns the index of the last occurrence of b1 or b2 in s,
// or -1 if neither is present.
func lastIndexByteTwo(s []byte, b1, b2 byte) int {
for i := len(s) - 1; i >= 0; i-- {
if s[i] == b1 || s[i] == b2 {
return i
}
}
return -1
}
+259
View File
@@ -0,0 +1,259 @@
package algo
import (
"bytes"
"testing"
)
func TestIndexByteTwo(t *testing.T) {
tests := []struct {
name string
s string
b1 byte
b2 byte
want int
}{
{"empty", "", 'a', 'b', -1},
{"single_b1", "a", 'a', 'b', 0},
{"single_b2", "b", 'a', 'b', 0},
{"single_none", "c", 'a', 'b', -1},
{"b1_first", "xaxb", 'a', 'b', 1},
{"b2_first", "xbxa", 'a', 'b', 1},
{"same_byte", "xxa", 'a', 'a', 2},
{"at_end", "xxxxa", 'a', 'b', 4},
{"not_found", "xxxxxxxx", 'a', 'b', -1},
{"long_b1_at_3000", string(make([]byte, 3000)) + "a" + string(make([]byte, 1000)), 'a', 'b', 3000},
{"long_b2_at_3000", string(make([]byte, 3000)) + "b" + string(make([]byte, 1000)), 'a', 'b', 3000},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := IndexByteTwo([]byte(tt.s), tt.b1, tt.b2)
if got != tt.want {
t.Errorf("IndexByteTwo(%q, %c, %c) = %d, want %d", tt.s[:min(len(tt.s), 40)], tt.b1, tt.b2, got, tt.want)
}
})
}
// Exhaustive test: compare against loop reference for various lengths,
// including sizes around SIMD block boundaries (16, 32, 64).
for n := 0; n <= 256; n++ {
data := make([]byte, n)
for i := range data {
data[i] = byte('c' + (i % 20))
}
// Test with match at every position
for pos := 0; pos < n; pos++ {
for _, b := range []byte{'A', 'B'} {
data[pos] = b
got := IndexByteTwo(data, 'A', 'B')
want := loopIndexByteTwo(data, 'A', 'B')
if got != want {
t.Fatalf("IndexByteTwo(len=%d, match=%c@%d) = %d, want %d", n, b, pos, got, want)
}
data[pos] = byte('c' + (pos % 20))
}
}
// Test with no match
got := IndexByteTwo(data, 'A', 'B')
if got != -1 {
t.Fatalf("IndexByteTwo(len=%d, no match) = %d, want -1", n, got)
}
// Test with both bytes present
if n >= 2 {
data[n/3] = 'A'
data[n*2/3] = 'B'
got := IndexByteTwo(data, 'A', 'B')
want := loopIndexByteTwo(data, 'A', 'B')
if got != want {
t.Fatalf("IndexByteTwo(len=%d, both@%d,%d) = %d, want %d", n, n/3, n*2/3, got, want)
}
data[n/3] = byte('c' + ((n / 3) % 20))
data[n*2/3] = byte('c' + ((n * 2 / 3) % 20))
}
}
}
func TestLastIndexByteTwo(t *testing.T) {
tests := []struct {
name string
s string
b1 byte
b2 byte
want int
}{
{"empty", "", 'a', 'b', -1},
{"single_b1", "a", 'a', 'b', 0},
{"single_b2", "b", 'a', 'b', 0},
{"single_none", "c", 'a', 'b', -1},
{"b1_last", "xbxa", 'a', 'b', 3},
{"b2_last", "xaxb", 'a', 'b', 3},
{"same_byte", "axx", 'a', 'a', 0},
{"at_start", "axxxx", 'a', 'b', 0},
{"both_present", "axbx", 'a', 'b', 2},
{"not_found", "xxxxxxxx", 'a', 'b', -1},
{"long_b1_at_3000", string(make([]byte, 3000)) + "a" + string(make([]byte, 1000)), 'a', 'b', 3000},
{"long_b2_at_end", string(make([]byte, 4000)) + "b", 'a', 'b', 4000},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := lastIndexByteTwo([]byte(tt.s), tt.b1, tt.b2)
if got != tt.want {
t.Errorf("lastIndexByteTwo(%q, %c, %c) = %d, want %d", tt.s[:min(len(tt.s), 40)], tt.b1, tt.b2, got, tt.want)
}
})
}
// Exhaustive test against loop reference
for n := 0; n <= 256; n++ {
data := make([]byte, n)
for i := range data {
data[i] = byte('c' + (i % 20))
}
for pos := 0; pos < n; pos++ {
for _, b := range []byte{'A', 'B'} {
data[pos] = b
got := lastIndexByteTwo(data, 'A', 'B')
want := refLastIndexByteTwo(data, 'A', 'B')
if got != want {
t.Fatalf("lastIndexByteTwo(len=%d, match=%c@%d) = %d, want %d", n, b, pos, got, want)
}
data[pos] = byte('c' + (pos % 20))
}
}
// No match
got := lastIndexByteTwo(data, 'A', 'B')
if got != -1 {
t.Fatalf("lastIndexByteTwo(len=%d, no match) = %d, want -1", n, got)
}
// Both bytes present
if n >= 2 {
data[n/3] = 'A'
data[n*2/3] = 'B'
got := lastIndexByteTwo(data, 'A', 'B')
want := refLastIndexByteTwo(data, 'A', 'B')
if got != want {
t.Fatalf("lastIndexByteTwo(len=%d, both@%d,%d) = %d, want %d", n, n/3, n*2/3, got, want)
}
data[n/3] = byte('c' + ((n / 3) % 20))
data[n*2/3] = byte('c' + ((n * 2 / 3) % 20))
}
}
}
func FuzzIndexByteTwo(f *testing.F) {
f.Add([]byte("hello world"), byte('o'), byte('l'))
f.Add([]byte(""), byte('a'), byte('b'))
f.Add([]byte("aaa"), byte('a'), byte('a'))
f.Fuzz(func(t *testing.T, data []byte, b1, b2 byte) {
got := IndexByteTwo(data, b1, b2)
want := loopIndexByteTwo(data, b1, b2)
if got != want {
t.Errorf("IndexByteTwo(len=%d, b1=%d, b2=%d) = %d, want %d", len(data), b1, b2, got, want)
}
})
}
func FuzzLastIndexByteTwo(f *testing.F) {
f.Add([]byte("hello world"), byte('o'), byte('l'))
f.Add([]byte(""), byte('a'), byte('b'))
f.Add([]byte("aaa"), byte('a'), byte('a'))
f.Fuzz(func(t *testing.T, data []byte, b1, b2 byte) {
got := lastIndexByteTwo(data, b1, b2)
want := refLastIndexByteTwo(data, b1, b2)
if got != want {
t.Errorf("lastIndexByteTwo(len=%d, b1=%d, b2=%d) = %d, want %d", len(data), b1, b2, got, want)
}
})
}
// Reference implementations for correctness checking
func refIndexByteTwo(s []byte, b1, b2 byte) int {
i1 := bytes.IndexByte(s, b1)
if i1 == 0 {
return 0
}
scope := s
if i1 > 0 {
scope = s[:i1]
}
if i2 := bytes.IndexByte(scope, b2); i2 >= 0 {
return i2
}
return i1
}
func loopIndexByteTwo(s []byte, b1, b2 byte) int {
for i, b := range s {
if b == b1 || b == b2 {
return i
}
}
return -1
}
func refLastIndexByteTwo(s []byte, b1, b2 byte) int {
for i := len(s) - 1; i >= 0; i-- {
if s[i] == b1 || s[i] == b2 {
return i
}
}
return -1
}
func benchIndexByteTwo(b *testing.B, size int, pos int) {
data := make([]byte, size)
for i := range data {
data[i] = byte('a' + (i % 20))
}
data[pos] = 'Z'
type impl struct {
name string
fn func([]byte, byte, byte) int
}
impls := []impl{
{"asm", IndexByteTwo},
{"2xIndexByte", refIndexByteTwo},
{"loop", loopIndexByteTwo},
}
for _, im := range impls {
b.Run(im.name, func(b *testing.B) {
for i := 0; i < b.N; i++ {
im.fn(data, 'Z', 'z')
}
})
}
}
func benchLastIndexByteTwo(b *testing.B, size int, pos int) {
data := make([]byte, size)
for i := range data {
data[i] = byte('a' + (i % 20))
}
data[pos] = 'Z'
type impl struct {
name string
fn func([]byte, byte, byte) int
}
impls := []impl{
{"asm", lastIndexByteTwo},
{"loop", refLastIndexByteTwo},
}
for _, im := range impls {
b.Run(im.name, func(b *testing.B) {
for i := 0; i < b.N; i++ {
im.fn(data, 'Z', 'z')
}
})
}
}
func BenchmarkIndexByteTwo_10(b *testing.B) { benchIndexByteTwo(b, 10, 8) }
func BenchmarkIndexByteTwo_100(b *testing.B) { benchIndexByteTwo(b, 100, 80) }
func BenchmarkIndexByteTwo_1000(b *testing.B) { benchIndexByteTwo(b, 1000, 800) }
func BenchmarkLastIndexByteTwo_10(b *testing.B) { benchLastIndexByteTwo(b, 10, 2) }
func BenchmarkLastIndexByteTwo_100(b *testing.B) { benchLastIndexByteTwo(b, 100, 20) }
func BenchmarkLastIndexByteTwo_1000(b *testing.B) { benchLastIndexByteTwo(b, 1000, 200) }
+98 -33
View File
@@ -6,6 +6,7 @@ import (
"strings"
"unicode/utf8"
"github.com/junegunn/fzf/src/algo"
"github.com/junegunn/fzf/src/tui"
)
@@ -22,20 +23,21 @@ type url struct {
type ansiState struct {
fg tui.Color
bg tui.Color
ul tui.Color
attr tui.Attr
lbg tui.Color
url *url
}
func (s *ansiState) colored() bool {
return s.fg != -1 || s.bg != -1 || s.attr > 0 || s.lbg >= 0 || s.url != nil
return s.fg != -1 || s.bg != -1 || s.ul != -1 || s.attr > 0 || s.lbg >= 0 || s.url != nil
}
func (s *ansiState) equals(t *ansiState) bool {
if t == nil {
return !s.colored()
}
return s.fg == t.fg && s.bg == t.bg && s.attr == t.attr && s.lbg == t.lbg && s.url == t.url
return s.fg == t.fg && s.bg == t.bg && s.ul == t.ul && s.attr == t.attr && s.lbg == t.lbg && s.url == t.url
}
func (s *ansiState) ToString() string {
@@ -54,7 +56,18 @@ func (s *ansiState) ToString() string {
ret += "3;"
}
if s.attr&tui.Underline > 0 {
ret += "4;"
switch s.attr.UnderlineStyle() {
case tui.UlStyleDouble:
ret += "4:2;"
case tui.UlStyleCurly:
ret += "4:3;"
case tui.UlStyleDotted:
ret += "4:4;"
case tui.UlStyleDashed:
ret += "4:5;"
default:
ret += "4;"
}
}
if s.attr&tui.Blink > 0 {
ret += "5;"
@@ -66,6 +79,9 @@ func (s *ansiState) ToString() string {
ret += "9;"
}
ret += toAnsiString(s.fg, 30) + toAnsiString(s.bg, 40)
if s.ul != -1 {
ret += toAnsiStringUl(s.ul)
}
ret = "\x1b[" + strings.TrimSuffix(ret, ";") + "m"
if s.url != nil {
@@ -74,6 +90,20 @@ func (s *ansiState) ToString() string {
return ret
}
func toAnsiStringUl(color tui.Color) string {
col := int(color)
if col < 0 {
return ""
}
if col >= (1 << 24) {
r := strconv.Itoa((col >> 16) & 0xff)
g := strconv.Itoa((col >> 8) & 0xff)
b := strconv.Itoa(col & 0xff)
return "58;2;" + r + ";" + g + ";" + b + ";"
}
return "58;5;" + strconv.Itoa(col) + ";"
}
func toAnsiString(color tui.Color, offset int) string {
col := int(color)
ret := ""
@@ -94,31 +124,31 @@ func toAnsiString(color tui.Color, offset int) string {
return ret + ";"
}
func isPrint(c uint8) bool {
return '\x20' <= c && c <= '\x7e'
}
func matchOperatingSystemCommand(s string, start int) int {
// `\x1b][0-9][;:][[:print:]]+(?:\x1b\\\\|\x07)`
// ^ match starting here after the first printable character
//
i := start // prefix matched in nextAnsiEscapeSequence()
for ; i < len(s) && isPrint(s[i]); i++ {
// Find the terminator: BEL (\x07) or ESC (\x1b) for ST (\x1b\\)
idx := algo.IndexByteTwo(stringBytes(s[i:]), '\x07', '\x1b')
if idx < 0 {
return -1
}
if i < len(s) {
if s[i] == '\x07' {
return i + 1
}
// `\x1b]8;PARAMS;URI\x1b\\TITLE\x1b]8;;\x1b`
// ------
if s[i] == '\x1b' && i < len(s)-1 && s[i+1] == '\\' {
return i + 2
}
i += idx
if s[i] == '\x07' {
return i + 1
}
// `\x1b]8;PARAMS;URI\x1b\\TITLE\x1b]8;;\x1b`
// ------
if i < len(s)-1 && s[i+1] == '\\' {
return i + 2
}
// `\x1b]8;PARAMS;URI\x1b\\TITLE\x1b]8;;\x1b`
// ------------
if i < len(s) && s[:i+1] == "\x1b]8;;\x1b" {
if s[:i+1] == "\x1b]8;;\x1b" {
return i + 1
}
@@ -204,7 +234,7 @@ Loop:
// \x1b][0-9]+[;:][[:print:]]+(?:\x1b\\\\|\x07)
// ---------------
if j > 2 && i+j+1 < len(s) && (s[i+j] == ';' || s[i+j] == ':') && isPrint(s[i+j+1]) {
if j > 2 && i+j+1 < len(s) && (s[i+j] == ';' || s[i+j] == ':') && s[i+j+1] >= '\x20' {
if k := matchOperatingSystemCommand(s[i:], j+2); k != -1 {
return i, i + k
}
@@ -338,15 +368,19 @@ func extractColor(str string, state *ansiState, proc func(string, *ansiState) bo
return trimmed, nil, state
}
func parseAnsiCode(s string) (int, string) {
func parseAnsiCode(s string) (int, byte, string) {
var remaining string
var i int
// Faster than strings.IndexAny(";:")
i = strings.IndexByte(s, ';')
if i < 0 {
i = strings.IndexByte(s, ':')
var sep byte
// Find the first separator (either ; or :)
i := -1
for j := 0; j < len(s); j++ {
if s[j] == ';' || s[j] == ':' {
i = j
break
}
}
if i >= 0 {
sep = s[i]
remaining = s[i+1:]
s = s[:i]
}
@@ -358,14 +392,14 @@ func parseAnsiCode(s string) (int, string) {
for _, ch := range stringBytes(s) {
ch -= '0'
if ch > 9 {
return -1, remaining
return -1, sep, remaining
}
code = code*10 + int(ch)
}
return code, remaining
return code, sep, remaining
}
return -1, remaining
return -1, sep, remaining
}
func interpretCode(ansiCode string, prevState *ansiState) ansiState {
@@ -373,14 +407,14 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
if prevState != nil {
return *prevState
}
return ansiState{-1, -1, 0, -1, nil}
return ansiState{-1, -1, -1, 0, -1, nil}
}
var state ansiState
if prevState == nil {
state = ansiState{-1, -1, 0, -1, nil}
state = ansiState{-1, -1, -1, 0, -1, nil}
} else {
state = ansiState{prevState.fg, prevState.bg, prevState.attr, prevState.lbg, prevState.url}
state = ansiState{prevState.fg, prevState.bg, prevState.ul, prevState.attr, prevState.lbg, prevState.url}
}
if ansiCode[0] != '\x1b' || ansiCode[1] != '[' || ansiCode[len(ansiCode)-1] != 'm' {
if prevState != nil && (strings.HasSuffix(ansiCode, "0K") || strings.HasSuffix(ansiCode, "[K")) {
@@ -405,6 +439,7 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
reset := func() {
state.fg = -1
state.bg = -1
state.ul = -1
state.attr = 0
}
@@ -420,7 +455,8 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
count := 0
for len(ansiCode) != 0 {
var num int
if num, ansiCode = parseAnsiCode(ansiCode); num != -1 {
var sep byte
if num, sep, ansiCode = parseAnsiCode(ansiCode); num != -1 {
count++
switch state256 {
case 0:
@@ -431,10 +467,15 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
case 48:
ptr = &state.bg
state256++
case 58:
ptr = &state.ul
state256++
case 39:
state.fg = -1
case 49:
state.bg = -1
case 59:
state.ul = -1
case 1:
state.attr = state.attr | tui.Bold
case 2:
@@ -442,7 +483,30 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
case 3:
state.attr = state.attr | tui.Italic
case 4:
state.attr = state.attr | tui.Underline
if sep == ':' {
// SGR 4:N - underline style sub-parameter
var subNum int
subNum, _, ansiCode = parseAnsiCode(ansiCode)
state.attr = state.attr &^ tui.UnderlineStyleMask
switch subNum {
case 0:
state.attr = state.attr &^ tui.Underline
case 1:
state.attr = state.attr | tui.Underline
case 2:
state.attr = state.attr | tui.Underline | tui.UlStyleDouble
case 3:
state.attr = state.attr | tui.Underline | tui.UlStyleCurly
case 4:
state.attr = state.attr | tui.Underline | tui.UlStyleDotted
case 5:
state.attr = state.attr | tui.Underline | tui.UlStyleDashed
default:
state.attr = state.attr | tui.Underline
}
} else {
state.attr = state.attr | tui.Underline
}
case 5:
state.attr = state.attr | tui.Blink
case 7:
@@ -456,6 +520,7 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
state.attr = state.attr &^ tui.Italic
case 24: // tput rmul
state.attr = state.attr &^ tui.Underline
state.attr = state.attr &^ tui.UnderlineStyleMask
case 25:
state.attr = state.attr &^ tui.Blink
case 27:
+127 -21
View File
@@ -41,7 +41,7 @@ func testParserReference(t testing.TB, str string) {
equal := len(got) == len(exp)
if equal {
for i := 0; i < len(got); i++ {
for i := range got {
if got[i] != exp[i] {
equal = false
break
@@ -167,9 +167,9 @@ func TestNextAnsiEscapeSequence_Fuzz_Random(t *testing.T) {
randomString := func(rr *rand.Rand) string {
numChars := rand.Intn(50)
codePoints := make([]rune, numChars)
for i := 0; i < len(codePoints); i++ {
for i := range codePoints {
var r rune
for n := 0; n < 1000; n++ {
for range 1000 {
r = rune(rr.Intn(utf8.MaxRune))
// Allow 10% of runes to be invalid
if utf8.ValidRune(r) || rr.Float64() < 0.10 {
@@ -182,7 +182,7 @@ func TestNextAnsiEscapeSequence_Fuzz_Random(t *testing.T) {
}
rr := rand.New(rand.NewSource(1))
for i := 0; i < 100_000; i++ {
for range 100_000 {
testParserReference(t, randomString(rr))
}
}
@@ -369,10 +369,10 @@ func TestAnsiCodeStringConversion(t *testing.T) {
}
}
assert("\x1b[m", nil, "")
assert("\x1b[m", &ansiState{attr: tui.Blink, lbg: -1}, "")
assert("\x1b[0m", &ansiState{fg: 4, bg: 4, lbg: -1}, "")
assert("\x1b[;m", &ansiState{fg: 4, bg: 4, lbg: -1}, "")
assert("\x1b[;;m", &ansiState{fg: 4, bg: 4, lbg: -1}, "")
assert("\x1b[m", &ansiState{attr: tui.Blink, ul: -1, lbg: -1}, "")
assert("\x1b[0m", &ansiState{fg: 4, bg: 4, ul: -1, lbg: -1}, "")
assert("\x1b[;m", &ansiState{fg: 4, bg: 4, ul: -1, lbg: -1}, "")
assert("\x1b[;;m", &ansiState{fg: 4, bg: 4, ul: -1, lbg: -1}, "")
assert("\x1b[31m", nil, "\x1b[31;49m")
assert("\x1b[41m", nil, "\x1b[39;41m")
@@ -380,36 +380,142 @@ func TestAnsiCodeStringConversion(t *testing.T) {
assert("\x1b[92m", nil, "\x1b[92;49m")
assert("\x1b[102m", nil, "\x1b[39;102m")
assert("\x1b[31m", &ansiState{fg: 4, bg: 4, lbg: -1}, "\x1b[31;44m")
assert("\x1b[1;2;31m", &ansiState{fg: 2, bg: -1, attr: tui.Reverse, lbg: -1}, "\x1b[1;2;7;31;49m")
assert("\x1b[31m", &ansiState{fg: 4, bg: 4, ul: -1, lbg: -1}, "\x1b[31;44m")
assert("\x1b[1;2;31m", &ansiState{fg: 2, bg: -1, ul: -1, attr: tui.Reverse, lbg: -1}, "\x1b[1;2;7;31;49m")
assert("\x1b[38;5;100;48;5;200m", nil, "\x1b[38;5;100;48;5;200m")
assert("\x1b[38:5:100:48:5:200m", nil, "\x1b[38;5;100;48;5;200m")
assert("\x1b[48;5;100;38;5;200m", nil, "\x1b[38;5;200;48;5;100m")
assert("\x1b[48;5;100;38;2;10;20;30;1m", nil, "\x1b[1;38;2;10;20;30;48;5;100m")
assert("\x1b[48;5;100;38;2;10;20;30;7m",
&ansiState{attr: tui.Dim | tui.Italic, fg: 1, bg: 1},
&ansiState{attr: tui.Dim | tui.Italic, fg: 1, bg: 1, ul: -1},
"\x1b[2;3;7;38;2;10;20;30;48;5;100m")
// Underline styles
assert("\x1b[4:3m", nil, "\x1b[4:3;39;49m")
assert("\x1b[4:2m", nil, "\x1b[4:2;39;49m")
assert("\x1b[4:4m", nil, "\x1b[4:4;39;49m")
assert("\x1b[4:5m", nil, "\x1b[4:5;39;49m")
assert("\x1b[4:1m", nil, "\x1b[4;39;49m")
// Underline color (256-color)
assert("\x1b[4;58;5;100m", nil, "\x1b[4;39;49;58;5;100m")
// Underline color (24-bit)
assert("\x1b[4;58;2;255;0;128m", nil, "\x1b[4;39;49;58;2;255;0;128m")
// Curly underline + underline color
assert("\x1b[4:3;58;2;255;0;0m", nil, "\x1b[4:3;39;49;58;2;255;0;0m")
// SGR 59 resets underline color
assert("\x1b[59m", &ansiState{fg: 1, bg: -1, ul: 100, lbg: -1}, "\x1b[31;49m")
}
func TestParseAnsiCode(t *testing.T) {
tests := []struct {
In, Exp string
N int
In string
Exp string
N int
Sep byte
}{
{"123", "", 123},
{"1a", "", -1},
{"1a;12", "12", -1},
{"12;a", "a", 12},
{"-2", "", -1},
{"123", "", 123, 0},
{"1a", "", -1, 0},
{"1a;12", "12", -1, ';'},
{"12;a", "a", 12, ';'},
{"-2", "", -1, 0},
// Colon sub-parameters: earliest separator wins (@shtse8)
{"4:3", "3", 4, ':'},
{"4:3;31", "3;31", 4, ':'},
{"38:2:255:0:0", "2:255:0:0", 38, ':'},
{"58:5:200", "5:200", 58, ':'},
// Semicolon before colon
{"4;38:2:0:0:0", "38:2:0:0:0", 4, ';'},
}
for _, x := range tests {
n, s := parseAnsiCode(x.In)
if n != x.N || s != x.Exp {
t.Fatalf("%q: got: (%d %q) want: (%d %q)", x.In, n, s, x.N, x.Exp)
n, sep, s := parseAnsiCode(x.In)
if n != x.N || s != x.Exp || sep != x.Sep {
t.Fatalf("%q: got: (%d %q %q) want: (%d %q %q)", x.In, n, s, string(sep), x.N, x.Exp, string(x.Sep))
}
}
}
// Test cases adapted from @shtse8 (PR #4678)
func TestInterpretCodeUnderlineStyles(t *testing.T) {
// 4:0 = no underline
state := interpretCode("\x1b[4:0m", nil)
if state.attr&tui.Underline != 0 {
t.Error("4:0 should not set underline")
}
// 4:1 = single underline
state = interpretCode("\x1b[4:1m", nil)
if state.attr&tui.Underline == 0 {
t.Error("4:1 should set underline")
}
// 4:3 = curly underline
state = interpretCode("\x1b[4:3m", nil)
if state.attr&tui.Underline == 0 {
t.Error("4:3 should set underline")
}
if state.attr.UnderlineStyle() != tui.UlStyleCurly {
t.Error("4:3 should set curly underline style")
}
// 4:3 should NOT set italic (3 is a sub-param, not SGR 3)
if state.attr&tui.Italic != 0 {
t.Error("4:3 should not set italic")
}
// 4:2;31 = double underline + red fg
state = interpretCode("\x1b[4:2;31m", nil)
if state.attr&tui.Underline == 0 {
t.Error("4:2;31 should set underline")
}
if state.fg != 1 {
t.Errorf("4:2;31 should set fg to red (1), got %d", state.fg)
}
if state.attr&tui.Dim != 0 {
t.Error("4:2;31 should not set dim")
}
// Plain 4 still works
state = interpretCode("\x1b[4m", nil)
if state.attr&tui.Underline == 0 {
t.Error("4 should set underline")
}
// 4;2 (semicolon) = underline + dim
state = interpretCode("\x1b[4;2m", nil)
if state.attr&tui.Underline == 0 {
t.Error("4;2 should set underline")
}
if state.attr&tui.Dim == 0 {
t.Error("4;2 should set dim")
}
}
// Test cases adapted from @shtse8 (PR #4678)
func TestInterpretCodeUnderlineColor(t *testing.T) {
// 58:2:R:G:B should not affect fg or bg
state := interpretCode("\x1b[58:2:255:0:0m", nil)
if state.fg != -1 || state.bg != -1 {
t.Errorf("58:2:R:G:B should not affect fg/bg, got fg=%d bg=%d", state.fg, state.bg)
}
// 58:5:200 should not affect fg or bg
state = interpretCode("\x1b[58:5:200m", nil)
if state.fg != -1 || state.bg != -1 {
t.Errorf("58:5:N should not affect fg/bg, got fg=%d bg=%d", state.fg, state.bg)
}
// 58:2:R:G:B combined with 38:2:R:G:B should only set fg
state = interpretCode("\x1b[58:2:255:0:0;38:2:0:255:0m", nil)
expectedFg := tui.Color(1<<24 | 0<<16 | 255<<8 | 0)
if state.fg != expectedFg {
t.Errorf("expected fg=%d, got %d", expectedFg, state.fg)
}
if state.bg != -1 {
t.Errorf("bg should be -1, got %d", state.bg)
}
}
// kernel/bpf/preload/iterators/README
const ansiBenchmarkString = "\x1b[38;5;81m\x1b[01;31m\x1b[Kkernel/\x1b[0m\x1b[38:5:81mbpf/" +
"\x1b[0m\x1b[38:5:81mpreload/\x1b[0m\x1b[38;5;81miterators/" +
+18 -15
View File
@@ -2,10 +2,13 @@ package fzf
import "sync"
// queryCache associates strings to lists of items
type queryCache map[string][]Result
// ChunkBitmap is a bitmap with one bit per item in a chunk.
type ChunkBitmap [chunkBitWords]uint64
// ChunkCache associates Chunk and query string to lists of items
// queryCache associates query strings to bitmaps of matching items
type queryCache map[string]ChunkBitmap
// ChunkCache associates Chunk and query string to bitmaps
type ChunkCache struct {
mutex sync.Mutex
cache map[*Chunk]*queryCache
@@ -30,9 +33,9 @@ func (cc *ChunkCache) retire(chunk ...*Chunk) {
cc.mutex.Unlock()
}
// Add adds the list to the cache
func (cc *ChunkCache) Add(chunk *Chunk, key string, list []Result) {
if len(key) == 0 || !chunk.IsFull() || len(list) > queryCacheMax {
// Add stores the bitmap for the given chunk and key
func (cc *ChunkCache) Add(chunk *Chunk, key string, bitmap ChunkBitmap, matchCount int) {
if len(key) == 0 || !chunk.IsFull() || matchCount > queryCacheMax {
return
}
@@ -44,11 +47,11 @@ func (cc *ChunkCache) Add(chunk *Chunk, key string, list []Result) {
cc.cache[chunk] = &queryCache{}
qc = cc.cache[chunk]
}
(*qc)[key] = list
(*qc)[key] = bitmap
}
// Lookup is called to lookup ChunkCache
func (cc *ChunkCache) Lookup(chunk *Chunk, key string) []Result {
// Lookup returns the bitmap for the exact key
func (cc *ChunkCache) Lookup(chunk *Chunk, key string) *ChunkBitmap {
if len(key) == 0 || !chunk.IsFull() {
return nil
}
@@ -58,15 +61,15 @@ func (cc *ChunkCache) Lookup(chunk *Chunk, key string) []Result {
qc, ok := cc.cache[chunk]
if ok {
list, ok := (*qc)[key]
if ok {
return list
if bm, ok := (*qc)[key]; ok {
return &bm
}
}
return nil
}
func (cc *ChunkCache) Search(chunk *Chunk, key string) []Result {
// Search finds the bitmap for the longest prefix or suffix of the key
func (cc *ChunkCache) Search(chunk *Chunk, key string) *ChunkBitmap {
if len(key) == 0 || !chunk.IsFull() {
return nil
}
@@ -86,8 +89,8 @@ func (cc *ChunkCache) Search(chunk *Chunk, key string) []Result {
prefix := key[:len(key)-idx]
suffix := key[idx:]
for _, substr := range [2]string{prefix, suffix} {
if cached, found := (*qc)[substr]; found {
return cached
if bm, found := (*qc)[substr]; found {
return &bm
}
}
}
+11 -11
View File
@@ -6,34 +6,34 @@ func TestChunkCache(t *testing.T) {
cache := NewChunkCache()
chunk1p := &Chunk{}
chunk2p := &Chunk{count: chunkSize}
items1 := []Result{{}}
items2 := []Result{{}, {}}
cache.Add(chunk1p, "foo", items1)
cache.Add(chunk2p, "foo", items1)
cache.Add(chunk2p, "bar", items2)
bm1 := ChunkBitmap{1}
bm2 := ChunkBitmap{1, 2}
cache.Add(chunk1p, "foo", bm1, 1)
cache.Add(chunk2p, "foo", bm1, 1)
cache.Add(chunk2p, "bar", bm2, 2)
{ // chunk1 is not full
cached := cache.Lookup(chunk1p, "foo")
if cached != nil {
t.Error("Cached disabled for non-empty chunks", cached)
t.Error("Cached disabled for non-full chunks", cached)
}
}
{
cached := cache.Lookup(chunk2p, "foo")
if cached == nil || len(cached) != 1 {
t.Error("Expected 1 item cached", cached)
if cached == nil || cached[0] != 1 {
t.Error("Expected bitmap cached", cached)
}
}
{
cached := cache.Lookup(chunk2p, "bar")
if cached == nil || len(cached) != 2 {
t.Error("Expected 2 items cached", cached)
if cached == nil || cached[1] != 2 {
t.Error("Expected bitmap cached", cached)
}
}
{
cached := cache.Lookup(chunk1p, "foobar")
if cached != nil {
t.Error("Expected 0 item cached", cached)
t.Error("Expected nil cached", cached)
}
}
}
+29
View File
@@ -52,6 +52,20 @@ func (cl *ChunkList) lastChunk() *Chunk {
return cl.chunks[len(cl.chunks)-1]
}
// GetItems returns the first n items from the given chunks
func GetItems(chunks []*Chunk, n int) []Item {
items := make([]Item, 0, n)
for _, chunk := range chunks {
for i := 0; i < chunk.count && len(items) < n; i++ {
items = append(items, chunk.items[i])
}
if len(items) >= n {
break
}
}
return items
}
// CountItems returns the total number of Items
func CountItems(cs []*Chunk) int {
if len(cs) == 0 {
@@ -85,6 +99,21 @@ func (cl *ChunkList) Clear() {
cl.mutex.Unlock()
}
// ForEachItem iterates all items and applies fn to each one.
// The done callback runs under the lock to safely update shared state.
func (cl *ChunkList) ForEachItem(fn func(*Item), done func()) {
cl.mutex.Lock()
for _, chunk := range cl.chunks {
for i := 0; i < chunk.count; i++ {
fn(&chunk.items[i])
}
}
if done != nil {
done()
}
cl.mutex.Unlock()
}
// Snapshot returns immutable snapshot of the ChunkList
func (cl *ChunkList) Snapshot(tail int) ([]*Chunk, int, bool) {
cl.mutex.Lock()
+2 -2
View File
@@ -51,7 +51,7 @@ func TestChunkList(t *testing.T) {
}
// Add more data
for i := 0; i < chunkSize*2; i++ {
for i := range chunkSize * 2 {
cl.Push(fmt.Appendf(nil, "item %d", i))
}
@@ -85,7 +85,7 @@ func TestChunkListTail(t *testing.T) {
return true
})
total := chunkSize*2 + chunkSize/2
for i := 0; i < total; i++ {
for i := range total {
cl.Push(fmt.Appendf(nil, "item %d", i))
}
+4 -6
View File
@@ -34,19 +34,18 @@ const (
maxBgProcessesPerAction = 3
// Matcher
numPartitionsMultiplier = 8
maxPartitions = 32
progressMinDuration = 200 * time.Millisecond
progressMinDuration = 200 * time.Millisecond
// Capacity of each chunk
chunkSize int = 100
chunkSize int = 1024
chunkBitWords = (chunkSize + 63) / 64
// Pre-allocated memory slices to minimize GC
slab16Size int = 100 * 1024 // 200KB * 32 = 12.8MB
slab32Size int = 2048 // 8KB * 32 = 256KB
// Do not cache results of low selectivity queries
queryCacheMax int = chunkSize / 5
queryCacheMax int = chunkSize / 2
// Not to cache mergers with large lists
mergerCacheMax int = 100000
@@ -65,7 +64,6 @@ const (
EvtSearchNew
EvtSearchProgress
EvtSearchFin
EvtHeader
EvtReady
EvtQuit
)
+179 -69
View File
@@ -2,7 +2,9 @@
package fzf
import (
"fmt"
"maps"
"math"
"os"
"sync"
"time"
@@ -17,7 +19,6 @@ Reader -> EvtReadNew -> Matcher (restart)
Terminal -> EvtSearchNew:bool -> Matcher (restart)
Matcher -> EvtSearchProgress -> Terminal (update info)
Matcher -> EvtSearchFin -> Terminal (update list)
Matcher -> EvtHeader -> Terminal (update header)
*/
type revision struct {
@@ -38,12 +39,27 @@ func (r revision) compatible(other revision) bool {
return r.major == other.major
}
func buildItemTransformer(opts *Options) func(*Item) string {
if opts.AcceptNth != nil {
fn := opts.AcceptNth(opts.Delimiter)
return func(item *Item) string {
return item.acceptNth(opts.Ansi, opts.Delimiter, fn)
}
}
return func(item *Item) string {
return item.AsString(opts.Ansi)
}
}
// Run starts fzf
func Run(opts *Options) (int, error) {
if opts.Filter == nil {
if opts.useTmux() {
return runTmux(os.Args, opts)
}
if opts.useZellij() {
return runZellij(os.Args, opts)
}
if needWinpty(opts) {
return runWinpty(os.Args, opts)
@@ -101,57 +117,57 @@ func Run(opts *Options) (int, error) {
cache := NewChunkCache()
var chunkList *ChunkList
var itemIndex int32
header := make([]string, 0, opts.HeaderLines)
// transformItem applies with-nth transformation to an item's raw data.
// It handles ANSI token propagation using prevLineAnsiState for cross-line continuity.
transformItem := func(item *Item, data []byte, transformer func([]Token, int32) string, index int32) {
tokens := Tokenize(byteString(data), opts.Delimiter)
if opts.Ansi && len(tokens) > 1 {
var ansiState *ansiState
if prevLineAnsiState != nil {
ansiStateDup := *prevLineAnsiState
ansiState = &ansiStateDup
}
for _, token := range tokens {
prevAnsiState := ansiState
_, _, ansiState = extractColor(token.text.ToString(), ansiState, nil)
if prevAnsiState != nil {
token.text.Prepend("\x1b[m" + prevAnsiState.ToString())
} else {
token.text.Prepend("\x1b[m")
}
}
}
transformed := transformer(tokens, index)
item.text, item.colors = ansiProcessor(stringBytes(transformed))
// We should not trim trailing whitespaces with background colors
var maxColorOffset int32
if item.colors != nil {
for _, ansi := range *item.colors {
if ansi.color.bg >= 0 {
maxColorOffset = max(maxColorOffset, ansi.offset[1])
}
}
}
item.text.TrimTrailingWhitespaces(int(maxColorOffset))
}
var nthTransformer func([]Token, int32) string
if opts.WithNth == nil {
chunkList = NewChunkList(cache, func(item *Item, data []byte) bool {
if len(header) < opts.HeaderLines {
header = append(header, byteString(data))
eventBox.Set(EvtHeader, header)
return false
}
item.text, item.colors = ansiProcessor(data)
item.text.Index = itemIndex
itemIndex++
return true
})
} else {
nthTransformer := opts.WithNth(opts.Delimiter)
nthTransformer = opts.WithNth(opts.Delimiter)
chunkList = NewChunkList(cache, func(item *Item, data []byte) bool {
tokens := Tokenize(byteString(data), opts.Delimiter)
if opts.Ansi && len(tokens) > 1 {
var ansiState *ansiState
if prevLineAnsiState != nil {
ansiStateDup := *prevLineAnsiState
ansiState = &ansiStateDup
}
for _, token := range tokens {
prevAnsiState := ansiState
_, _, ansiState = extractColor(token.text.ToString(), ansiState, nil)
if prevAnsiState != nil {
token.text.Prepend("\x1b[m" + prevAnsiState.ToString())
} else {
token.text.Prepend("\x1b[m")
}
}
if nthTransformer == nil {
item.text, item.colors = ansiProcessor(data)
} else {
transformItem(item, data, nthTransformer, itemIndex)
}
transformed := nthTransformer(tokens, itemIndex)
if len(header) < opts.HeaderLines {
header = append(header, transformed)
eventBox.Set(EvtHeader, header)
return false
}
item.text, item.colors = ansiProcessor(stringBytes(transformed))
// We should not trim trailing whitespaces with background colors
var maxColorOffset int32
if item.colors != nil {
for _, ansi := range *item.colors {
if ansi.color.bg >= 0 {
maxColorOffset = util.Max32(maxColorOffset, ansi.offset[1])
}
}
}
item.text.TrimTrailingWhitespaces(int(maxColorOffset))
item.text.Index = itemIndex
item.origText = &data
itemIndex++
@@ -181,13 +197,15 @@ func Run(opts *Options) (int, error) {
}
// Reader
streamingFilter := opts.Filter != nil && !sort && !opts.Tac && !opts.Sync
streamingFilter := opts.Filter != nil && !sort && !opts.Tac && !opts.Sync && opts.Bench == 0
var reader *Reader
var ingestionStart time.Time
if !streamingFilter {
reader = NewReader(func(data []byte) bool {
return chunkList.Push(data)
}, eventBox, executor, opts.ReadZero, opts.Filter == nil)
ingestionStart = time.Now()
readyChan := make(chan bool)
go reader.ReadSource(opts.Input, opts.WalkerRoot, opts.WalkerOpts, opts.WalkerSkip, initialReload, initialEnv, readyChan)
<-readyChan
@@ -224,15 +242,20 @@ func Run(opts *Options) (int, error) {
denylist = make(map[int32]struct{})
denyMutex.Unlock()
}
if opts.HeaderLines > math.MaxInt32 {
opts.HeaderLines = math.MaxInt32
}
headerLines := int32(opts.HeaderLines)
headerUpdated := false
patternBuilder := func(runes []rune) *Pattern {
denyMutex.Lock()
denylistCopy := maps.Clone(denylist)
denyMutex.Unlock()
return BuildPattern(cache, patternCache,
opts.Fuzzy, opts.FuzzyAlgo, opts.Extended, opts.Case, opts.Normalize, forward, withPos,
opts.Filter == nil, nth, opts.Delimiter, inputRevision, runes, denylistCopy)
opts.Filter == nil, nth, opts.Delimiter, inputRevision, runes, denylistCopy, headerLines)
}
matcher := NewMatcher(cache, patternBuilder, sort, opts.Tac, eventBox, inputRevision)
matcher := NewMatcher(cache, patternBuilder, sort, opts.Tac, eventBox, inputRevision, opts.Threads)
// Filtering mode
if opts.Filter != nil {
@@ -243,6 +266,8 @@ func Run(opts *Options) (int, error) {
pattern := patternBuilder([]rune(*opts.Filter))
matcher.sort = pattern.sortable
transformer := buildItemTransformer(opts)
found := false
if streamingFilter {
slab := util.MakeSlab(slab16Size, slab32Size)
@@ -251,9 +276,12 @@ func Run(opts *Options) (int, error) {
func(runes []byte) bool {
item := Item{}
if chunkList.trans(&item, runes) {
if item.Index() < headerLines {
return false
}
mutex.Lock()
if result, _, _ := pattern.MatchItem(&item, false, slab); result != nil {
opts.Printer(item.text.ToString())
if result, _, _ := pattern.MatchItem(&item, false, slab); result.item != nil {
opts.Printer(transformer(&item))
found = true
}
mutex.Unlock()
@@ -264,14 +292,56 @@ func Run(opts *Options) (int, error) {
} else {
eventBox.Unwatch(EvtReadNew)
eventBox.WaitFor(EvtReadFin)
ingestionTime := time.Since(ingestionStart)
// NOTE: Streaming filter is inherently not compatible with --tail
snapshot, _, _ := chunkList.Snapshot(opts.Tail)
if opts.Bench > 0 {
// Benchmark mode: repeat scan for the given duration
totalItems := CountItems(snapshot)
var matchCount int
var times []time.Duration
deadline := time.Now().Add(opts.Bench)
for time.Now().Before(deadline) {
cache.Clear()
start := time.Now()
result := matcher.scan(MatchRequest{
chunks: snapshot,
pattern: pattern})
times = append(times, time.Since(start))
matchCount = result.merger.Length()
}
// Print stats
var total time.Duration
minD, maxD := times[0], times[0]
for _, d := range times {
total += d
if d < minD {
minD = d
}
if d > maxD {
maxD = d
}
}
avg := total / time.Duration(len(times))
selectivity := float64(matchCount) / float64(totalItems) * 100
fmt.Printf(" %d iterations avg: %.2fms min: %.2fms max: %.2fms total: %.2fs items: %d matches: %d (%.2f%%) ingestion: %.2fms\n",
len(times),
float64(avg.Microseconds())/1000,
float64(minD.Microseconds())/1000,
float64(maxD.Microseconds())/1000,
total.Seconds(),
totalItems, matchCount, selectivity,
float64(ingestionTime.Microseconds())/1000)
return ExitOk, nil
}
result := matcher.scan(MatchRequest{
chunks: snapshot,
pattern: pattern})
for i := 0; i < result.merger.Length(); i++ {
opts.Printer(result.merger.Get(i).item.AsString(opts.Ansi))
opts.Printer(transformer(result.merger.Get(i).item))
found = true
}
}
@@ -316,10 +386,11 @@ func Run(opts *Options) (int, error) {
query := []rune{}
determine := func(final bool) {
if heightUnknown {
if total >= maxFit || final {
items := max(0, total-int(headerLines))
if items >= maxFit || final {
deferred = false
heightUnknown = false
terminal.startChan <- fitpad{util.Min(total, maxFit), padHeight}
terminal.startChan <- fitpad{min(items, maxFit), padHeight}
}
} else if deferred {
deferred = false
@@ -335,11 +406,11 @@ func Run(opts *Options) (int, error) {
clearDenylist()
}
reading = true
headerUpdated = false
startTick = ticks
chunkList.Clear()
itemIndex = 0
inputRevision.bumpMajor()
header = make([]string, 0, opts.HeaderLines)
readyChan := make(chan bool)
go reader.restart(command, environ, readyChan)
<-readyChan
@@ -397,16 +468,24 @@ func Run(opts *Options) (int, error) {
snapshotRevision = inputRevision
}
total = count
terminal.UpdateCount(total, !reading, value.(*string))
terminal.UpdateCount(max(0, total-int(headerLines)), !reading, value.(*string))
if headerLines > 0 && !headerUpdated {
terminal.UpdateHeader(GetItems(snapshot, int(headerLines)))
headerUpdated = total >= int(headerLines)
}
if heightUnknown && !deferred {
determine(!reading)
}
matcher.Reset(snapshot, input(), false, !reading, sort, snapshotRevision)
if !useSnapshot || evt == EvtReadFin {
matcher.Reset(snapshot, input(), false, !reading, sort, snapshotRevision)
}
case EvtSearchNew:
var command *commandSpec
var environ []string
var changed bool
headerLinesChanged := false
withNthChanged := false
switch val := value.(type) {
case searchRequest:
sort = val.sort
@@ -427,6 +506,40 @@ func Run(opts *Options) (int, error) {
nth = *val.nth
bump = true
}
if val.headerLines != nil {
headerLines = int32(*val.headerLines)
headerUpdated = false
headerLinesChanged = true
bump = true
}
if val.withNth != nil {
newTransformer := val.withNth.fn
// Cancel any in-flight scan and block the terminal from reading
// items before mutating them in-place. Snapshot shares middle
// chunk pointers, so the matcher and terminal can race with us.
matcher.CancelScan()
terminal.PauseRendering()
// Reset cross-line ANSI state before re-processing all items
lineAnsiState = nil
prevLineAnsiState = nil
chunkList.ForEachItem(func(item *Item) {
origBytes := *item.origText
savedIndex := item.Index()
if newTransformer != nil {
transformItem(item, origBytes, newTransformer, savedIndex)
} else {
item.text, item.colors = ansiProcessor(origBytes)
}
item.text.Index = savedIndex
item.transformed = nil
}, func() {
nthTransformer = newTransformer
})
terminal.ResumeRendering()
matcher.ResumeScan()
withNthChanged = true
bump = true
}
if bump {
patternCache = make(map[string]*Pattern)
cache.Clear()
@@ -463,6 +576,16 @@ func Run(opts *Options) (int, error) {
snapshotRevision = inputRevision
}
}
if headerLinesChanged {
terminal.UpdateCount(max(0, total-int(headerLines)), !reading, nil)
if headerLines > 0 {
terminal.UpdateHeader(GetItems(snapshot, int(headerLines)))
} else {
terminal.UpdateHeader(nil)
}
} else if withNthChanged && headerLines > 0 {
terminal.UpdateHeader(GetItems(snapshot, int(headerLines)))
}
matcher.Reset(snapshot, input(), true, !reading, sort, snapshotRevision)
delay = false
@@ -472,11 +595,6 @@ func Run(opts *Options) (int, error) {
terminal.UpdateProgress(val)
}
case EvtHeader:
headerPadded := make([]string, opts.HeaderLines)
copy(headerPadded, value.([]string))
terminal.UpdateHeader(headerPadded)
case EvtSearchFin:
switch val := value.(type) {
case MatchResult:
@@ -493,16 +611,8 @@ func Run(opts *Options) (int, error) {
if len(opts.Expect) > 0 {
opts.Printer("")
}
transformer := func(item *Item) string {
return item.AsString(opts.Ansi)
}
if opts.AcceptNth != nil {
fn := opts.AcceptNth(opts.Delimiter)
transformer = func(item *Item) string {
return item.acceptNth(opts.Ansi, opts.Delimiter, fn)
}
}
for i := 0; i < count; i++ {
transformer := buildItemTransformer(opts)
for i := range count {
opts.Printer(transformer(merger.Get(i).item))
}
if count == 0 {
@@ -524,7 +634,7 @@ func Run(opts *Options) (int, error) {
break
}
if delay && reading {
dur := util.DurWithin(
dur := util.Constrain(
time.Duration(ticks-startTick)*coordinatorDelayStep,
0, coordinatorDelayMax)
time.Sleep(dur)
+1 -1
View File
@@ -38,7 +38,7 @@ func TestHistory(t *testing.T) {
if len(h.lines) != maxHistory+1 {
t.Errorf("Expected: %d, actual: %d\n", maxHistory+1, len(h.lines))
}
for i := 0; i < maxHistory; i++ {
for i := range maxHistory {
if h.lines[i] != "foobar" {
t.Error("Expected: foobar, actual: " + h.lines[i])
}
+50 -53
View File
@@ -3,8 +3,8 @@ package fzf
import (
"fmt"
"runtime"
"sort"
"sync"
"sync/atomic"
"time"
"github.com/junegunn/fzf/src/util"
@@ -43,8 +43,11 @@ type Matcher struct {
reqBox *util.EventBox
partitions int
slab []*util.Slab
sortBuf [][]Result
mergerCache map[string]MatchResult
revision revision
scanMutex sync.Mutex
cancelScan *util.AtomicBool
}
const (
@@ -54,8 +57,11 @@ const (
// NewMatcher returns a new Matcher
func NewMatcher(cache *ChunkCache, patternBuilder func([]rune) *Pattern,
sort bool, tac bool, eventBox *util.EventBox, revision revision) *Matcher {
partitions := util.Min(numPartitionsMultiplier*runtime.NumCPU(), maxPartitions)
sort bool, tac bool, eventBox *util.EventBox, revision revision, threads int) *Matcher {
partitions := runtime.NumCPU()
if threads > 0 {
partitions = threads
}
return &Matcher{
cache: cache,
patternBuilder: patternBuilder,
@@ -65,8 +71,10 @@ func NewMatcher(cache *ChunkCache, patternBuilder func([]rune) *Pattern,
reqBox: util.NewEventBox(),
partitions: partitions,
slab: make([]*util.Slab, partitions),
sortBuf: make([][]Result, partitions),
mergerCache: make(map[string]MatchResult),
revision: revision}
revision: revision,
cancelScan: util.NewAtomicBool(false)}
}
// Loop puts Matcher in action
@@ -126,7 +134,9 @@ func (m *Matcher) Loop() {
}
if result.merger == nil {
m.scanMutex.Lock()
result = m.scan(request)
m.scanMutex.Unlock()
}
if !result.cancelled {
@@ -139,27 +149,6 @@ func (m *Matcher) Loop() {
}
}
func (m *Matcher) sliceChunks(chunks []*Chunk) [][]*Chunk {
partitions := m.partitions
perSlice := len(chunks) / partitions
if perSlice == 0 {
partitions = len(chunks)
perSlice = 1
}
slices := make([][]*Chunk, partitions)
for i := 0; i < partitions; i++ {
start := i * perSlice
end := start + perSlice
if i == partitions-1 {
end = len(chunks)
}
slices[i] = chunks[start:end]
}
return slices
}
type partialResult struct {
index int
matches []Result
@@ -174,7 +163,7 @@ func (m *Matcher) scan(request MatchRequest) MatchResult {
return MatchResult{m, m, false}
}
pattern := request.pattern
passMerger := PassMerger(&request.chunks, m.tac, request.revision)
passMerger := PassMerger(&request.chunks, m.tac, request.revision, pattern.startIndex)
if pattern.IsEmpty() {
return MatchResult{passMerger, passMerger, false}
}
@@ -183,43 +172,37 @@ func (m *Matcher) scan(request MatchRequest) MatchResult {
maxIndex := request.chunks[numChunks-1].lastIndex(minIndex)
cancelled := util.NewAtomicBool(false)
slices := m.sliceChunks(request.chunks)
numSlices := len(slices)
resultChan := make(chan partialResult, numSlices)
numWorkers := min(m.partitions, numChunks)
var nextChunk atomic.Int32
resultChan := make(chan partialResult, numWorkers)
countChan := make(chan int, numChunks)
waitGroup := sync.WaitGroup{}
for idx, chunks := range slices {
for idx := range numWorkers {
waitGroup.Add(1)
if m.slab[idx] == nil {
m.slab[idx] = util.MakeSlab(slab16Size, slab32Size)
}
go func(idx int, slab *util.Slab, chunks []*Chunk) {
defer func() { waitGroup.Done() }()
count := 0
allMatches := make([][]Result, len(chunks))
for idx, chunk := range chunks {
matches := request.pattern.Match(chunk, slab)
allMatches[idx] = matches
count += len(matches)
go func(idx int, slab *util.Slab) {
defer waitGroup.Done()
var matches []Result
for {
ci := int(nextChunk.Add(1)) - 1
if ci >= numChunks {
break
}
chunkMatches := request.pattern.Match(request.chunks[ci], slab)
matches = append(matches, chunkMatches...)
if cancelled.Get() {
return
}
countChan <- len(matches)
}
sliceMatches := make([]Result, 0, count)
for _, matches := range allMatches {
sliceMatches = append(sliceMatches, matches...)
countChan <- len(chunkMatches)
}
if m.sort && request.pattern.sortable {
if m.tac {
sort.Sort(ByRelevanceTac(sliceMatches))
} else {
sort.Sort(ByRelevance(sliceMatches))
}
m.sortBuf[idx] = radixSortResults(matches, m.tac, m.sortBuf[idx])
}
resultChan <- partialResult{idx, sliceMatches}
}(idx, m.slab[idx], chunks)
resultChan <- partialResult{idx, matches}
}(idx, m.slab[idx])
}
wait := func() bool {
@@ -238,7 +221,7 @@ func (m *Matcher) scan(request MatchRequest) MatchResult {
break
}
if m.reqBox.Peek(reqReset) {
if m.cancelScan.Get() || m.reqBox.Peek(reqReset) {
return MatchResult{nil, nil, wait()}
}
@@ -247,8 +230,8 @@ func (m *Matcher) scan(request MatchRequest) MatchResult {
}
}
partialResults := make([][]Result, numSlices)
for range slices {
partialResults := make([][]Result, numWorkers)
for range numWorkers {
partialResult := <-resultChan
partialResults[partialResult.index] = partialResult.matches
}
@@ -269,6 +252,20 @@ func (m *Matcher) Reset(chunks []*Chunk, patternRunes []rune, cancel bool, final
m.reqBox.Set(event, MatchRequest{chunks, pattern, final, sort, revision})
}
// CancelScan cancels any in-flight scan, waits for it to finish,
// and prevents new scans from starting until ResumeScan is called.
// This is used to safely mutate shared items (e.g., during with-nth changes).
func (m *Matcher) CancelScan() {
m.cancelScan.Set(true)
m.scanMutex.Lock()
m.cancelScan.Set(false)
}
// ResumeScan allows scans to proceed again after CancelScan.
func (m *Matcher) ResumeScan() {
m.scanMutex.Unlock()
}
func (m *Matcher) Stop() {
m.reqBox.Set(reqQuit, nil)
}
+30 -32
View File
@@ -10,42 +10,46 @@ func EmptyMerger(revision revision) *Merger {
// Merger holds a set of locally sorted lists of items and provides the view of
// a single, globally-sorted list
type Merger struct {
pattern *Pattern
lists [][]Result
merged []Result
chunks *[]*Chunk
cursors []int
sorted bool
tac bool
final bool
count int
pass bool
revision revision
minIndex int32
maxIndex int32
pattern *Pattern
lists [][]Result
merged []Result
chunks *[]*Chunk
cursors []int
sorted bool
tac bool
final bool
count int
pass bool
startIndex int
revision revision
minIndex int32
maxIndex int32
}
// PassMerger returns a new Merger that simply returns the items in the
// original order
func PassMerger(chunks *[]*Chunk, tac bool, revision revision) *Merger {
// original order. startIndex items are skipped from the beginning.
func PassMerger(chunks *[]*Chunk, tac bool, revision revision, startIndex int32) *Merger {
var minIndex, maxIndex int32
if len(*chunks) > 0 {
minIndex = (*chunks)[0].items[0].Index()
maxIndex = (*chunks)[len(*chunks)-1].lastIndex(minIndex)
}
si := int(startIndex)
mg := Merger{
pattern: nil,
chunks: chunks,
tac: tac,
count: 0,
pass: true,
revision: revision,
minIndex: minIndex,
maxIndex: maxIndex}
pattern: nil,
chunks: chunks,
tac: tac,
count: 0,
pass: true,
startIndex: si,
revision: revision,
minIndex: minIndex + startIndex,
maxIndex: maxIndex}
for _, chunk := range *mg.chunks {
mg.count += chunk.count
}
mg.count = max(0, mg.count-si)
return &mg
}
@@ -113,6 +117,7 @@ func (mg *Merger) Get(idx int) Result {
if mg.tac {
idx = mg.count - idx - 1
}
idx += mg.startIndex
firstChunk := (*mg.chunks)[0]
if firstChunk.count < chunkSize && idx >= firstChunk.count {
idx -= firstChunk.count
@@ -131,14 +136,7 @@ func (mg *Merger) Get(idx int) Result {
if mg.tac {
idx = mg.count - idx - 1
}
for _, list := range mg.lists {
numItems := len(list)
if idx < numItems {
return list[idx]
}
idx -= numItems
}
panic(fmt.Sprintf("Index out of bounds (unsorted, %d/%d)", idx, mg.count))
return mg.mergedGet(idx)
}
func (mg *Merger) ToMap() map[int32]Result {
@@ -166,7 +164,7 @@ func (mg *Merger) mergedGet(idx int) Result {
}
if cursor >= 0 {
rank := list[cursor]
if minIdx < 0 || compareRanks(rank, minRank, mg.tac) {
if minIdx < 0 || mg.sorted && compareRanks(rank, minRank, mg.tac) || !mg.sorted && rank.item.Index() < minRank.item.Index() {
minRank = rank
minIdx = listIdx
}
+21 -6
View File
@@ -34,11 +34,11 @@ func buildLists(partiallySorted bool) ([][]Result, []Result) {
numLists := 4
lists := make([][]Result, numLists)
cnt := 0
for i := 0; i < numLists; i++ {
for i := range numLists {
numResults := rand.Int() % 20
cnt += numResults
lists[i] = make([]Result, numResults)
for j := 0; j < numResults; j++ {
for j := range numResults {
item := randResult()
lists[i][j] = item
}
@@ -54,13 +54,28 @@ func buildLists(partiallySorted bool) ([][]Result, []Result) {
}
func TestMergerUnsorted(t *testing.T) {
lists, items := buildLists(false)
lists, _ := buildLists(false)
// Sort each list by index to simulate real worker behavior
// (workers process chunks in ascending order via nextChunk.Add(1))
for _, list := range lists {
sort.Slice(list, func(i, j int) bool {
return list[i].item.Index() < list[j].item.Index()
})
}
items := []Result{}
for _, list := range lists {
items = append(items, list...)
}
sort.Slice(items, func(i, j int) bool {
return items[i].item.Index() < items[j].item.Index()
})
cnt := len(items)
// Not sorted: same order
// Not sorted: items in ascending index order
mg := NewMerger(nil, lists, false, false, revision{}, 0, 0)
assert(t, cnt == mg.Length(), "Invalid Length")
for i := 0; i < cnt; i++ {
for i := range cnt {
assert(t, items[i] == mg.Get(i), "Invalid Get")
}
}
@@ -73,7 +88,7 @@ func TestMergerSorted(t *testing.T) {
mg := NewMerger(nil, lists, true, false, revision{}, 0, 0)
assert(t, cnt == mg.Length(), "Invalid Length")
sort.Sort(ByRelevance(items))
for i := 0; i < cnt; i++ {
for i := range cnt {
if items[i] != mg.Get(i) {
t.Error("Not sorted", items[i], mg.Get(i))
}
+255 -49
View File
@@ -4,11 +4,12 @@ import (
"errors"
"fmt"
"maps"
"math"
"os"
"regexp"
"strconv"
"strings"
"unicode"
"time"
"github.com/junegunn/fzf/src/algo"
"github.com/junegunn/fzf/src/tui"
@@ -65,7 +66,7 @@ Usage: fzf [options]
--no-bold Do not use bold text
DISPLAY MODE
--height=[~]HEIGHT[%] Display fzf window below the cursor with the given
--height=[~][-]HEIGHT[%] Display fzf window below the cursor with the given
height instead of using fullscreen.
A negative value is calculated as the terminal height
minus the given value.
@@ -74,16 +75,17 @@ Usage: fzf [options]
--min-height=HEIGHT[+] Minimum height when --height is given as a percentage.
Add '+' to automatically increase the value
according to the other layout options (default: 10+).
--tmux[=OPTS] Start fzf in a tmux popup (requires tmux 3.3+)
--popup[=OPTS] Start fzf in a popup window (requires tmux 3.3+ or Zellij 0.44+)
[center|top|bottom|left|right][,SIZE[%]][,SIZE[%]]
[,border-native] (default: center,50%)
--tmux[=OPTS] Alias for --popup
LAYOUT
--layout=LAYOUT Choose layout: [default|reverse|reverse-list]
--margin=MARGIN Screen margin (TRBL | TB,RL | T,RL,B | T,R,B,L)
--padding=PADDING Padding inside border (TRBL | TB,RL | T,RL,B | T,R,B,L)
--border[=STYLE] Draw border around the finder
[rounded|sharp|bold|block|thinblock|double|horizontal|vertical|
[rounded|sharp|bold|block|thinblock|double|dashed|horizontal|vertical|
top|bottom|left|right|line|none] (default: rounded)
--border-label=LABEL Label to print on the border
--border-label-pos=COL Position of the border label
@@ -95,15 +97,18 @@ Usage: fzf [options]
-m, --multi[=MAX] Enable multi-select with tab/shift-tab
--highlight-line Highlight the whole current line
--cycle Enable cyclic scroll
--wrap Enable line wrap
--wrap[=MODE] Enable line wrap (char|word, default: char)
--wrap-sign=STR Indicator for wrapped lines
--no-multi-line Disable multi-line display of items when using --read0
--raw Enable raw mode (show non-matching items)
--track Track the current selection when the result is updated
--id-nth=N[,..] Define item identity fields for cross-reload operations
--tac Reverse the order of the input
--gap[=N] Render empty lines between each item
--gap-line[=STR] Draw horizontal line on each gap using the string
(default: '┈' or '-')
--freeze-left=N Number of fields to freeze on the left
--freeze-right=N Number of fields to freeze on the right
--keep-right Keep the right end of the line visible on overflow
--scroll-off=LINES Number of screen lines to keep above or below when
scrolling to the top or to the bottom (default: 0)
@@ -123,7 +128,7 @@ Usage: fzf [options]
(each for list section and preview window)
--no-scrollbar Hide scrollbar
--list-border[=STYLE] Draw border around the list section
[rounded|sharp|bold|block|thinblock|double|horizontal|vertical|
[rounded|sharp|bold|block|thinblock|double|dashed|horizontal|vertical|
top|bottom|left|right|none] (default: rounded)
--list-label=LABEL Label to print on the list border
--list-label-pos=COL Position of the list label
@@ -143,7 +148,7 @@ Usage: fzf [options]
--ghost=TEXT Ghost text to display when the input is empty
--filepath-word Make word-wise movements respect path separators
--input-border[=STYLE] Draw border around the input section
[rounded|sharp|bold|block|thinblock|double|horizontal|vertical|
[rounded|sharp|bold|block|thinblock|double|dashed|horizontal|vertical|
top|bottom|left|right|line|none] (default: rounded)
--input-label=LABEL Label to print on the input border
--input-label-pos=COL Position of the input label
@@ -154,28 +159,30 @@ Usage: fzf [options]
PREVIEW WINDOW
--preview=COMMAND Command to preview highlighted line ({})
--preview-window=OPT Preview window layout (default: right:50%)
[up|down|left|right][,SIZE[%]]
[,[no]wrap][,[no]cycle][,[no]follow][,[no]info]
[up|down|left|right|next][,SIZE[%]]
[,[no]wrap[-word]][,[no]cycle][,[no]follow][,[no]info]
[,[no]hidden][,border-STYLE]
[,+SCROLL[OFFSETS][/DENOM]][,~HEADER_LINES]
[,default][,<SIZE_THRESHOLD(ALTERNATIVE_LAYOUT)]
--preview-border[=STYLE] Short for --preview-window=border-STYLE
[rounded|sharp|bold|block|thinblock|double|horizontal|vertical|
[rounded|sharp|bold|block|thinblock|double|dashed|horizontal|vertical|
top|bottom|left|right|line|none] (default: rounded)
--preview-label=LABEL
--preview-label-pos=N Same as --border-label and --border-label-pos,
but for preview window
--preview-wrap-sign=STR Indicator for wrapped lines in the preview window
HEADER
--header=STR String to print as header
--header-lines=N The first N lines of the input are treated as header
--header-first Print header before the prompt line
--header-border[=STYLE] Draw border around the header section
[rounded|sharp|bold|block|thinblock|double|horizontal|vertical|
top|bottom|left|right|line|none] (default: rounded)
[rounded|sharp|bold|block|thinblock|double|dashed|horizontal|vertical|
top|bottom|left|right|line|inline|none] (default: rounded)
--header-lines-border[=STYLE]
Display header from --header-lines with a separate border.
Pass 'none' to still separate it but without a border.
Pass 'inline' to embed it inside the list frame.
--header-label=LABEL Label to print on the header border
--header-label-pos=COL Position of the header label
[POSITIVE_INTEGER: columns from left|
@@ -185,8 +192,8 @@ Usage: fzf [options]
FOOTER
--footer=STR String to print as footer
--footer-border[=STYLE] Draw border around the footer section
[rounded|sharp|bold|block|thinblock|double|horizontal|vertical|
top|bottom|left|right|line|none] (default: line)
[rounded|sharp|bold|block|thinblock|double|dashed|horizontal|vertical|
top|bottom|left|right|line|inline|none] (default: line)
--footer-label=LABEL Label to print on the footer border
--footer-label-pos=COL Position of the footer label
[POSITIVE_INTEGER: columns from left|
@@ -225,6 +232,7 @@ Usage: fzf [options]
--bash Print script to set up Bash shell integration
--zsh Print script to set up Zsh shell integration
--fish Print script to set up Fish shell integration
--nushell Print script to set up Nushell integration
HELP
--version Display version information and exit
@@ -290,14 +298,32 @@ func defaultMargin() [4]sizeSpec {
return [4]sizeSpec{}
}
type trackOption int
type trackOption struct {
enabled bool
index int32
}
const (
trackDisabled trackOption = iota
trackEnabled
trackCurrent
var (
trackDisabled = trackOption{false, minItem.Index()}
trackEnabled = trackOption{true, minItem.Index()}
)
func (t trackOption) Disabled() bool {
return !t.enabled
}
func (t trackOption) Global() bool {
return t.enabled && t.index == minItem.Index()
}
func (t trackOption) Current() bool {
return t.enabled && t.index != minItem.Index()
}
func trackCurrent(index int32) trackOption {
return trackOption{true, index}
}
type windowPosition int
const (
@@ -306,6 +332,7 @@ const (
posLeft
posRight
posCenter
posNext // adjacent to the input section, on the list side
)
type tmuxOptions struct {
@@ -347,6 +374,7 @@ type previewOpts struct {
scroll string
hidden bool
wrap bool
wrapWord bool
cycle bool
follow bool
info bool
@@ -364,7 +392,7 @@ func (o *previewOpts) Toggle() {
o.hidden = !o.hidden
}
func (o *previewOpts) Border() tui.BorderShape {
func (o *previewOpts) Border(layout layoutType) tui.BorderShape {
shape := o.border
if shape == tui.BorderLine {
switch o.position {
@@ -376,6 +404,12 @@ func (o *previewOpts) Border() tui.BorderShape {
shape = tui.BorderRight
case posRight:
shape = tui.BorderLeft
case posNext:
if layout == layoutReverse {
shape = tui.BorderBottom
} else {
shape = tui.BorderTop
}
}
}
return shape
@@ -393,7 +427,7 @@ func parseTmuxOptions(arg string, index int) (*tmuxOptions, error) {
var err error
opts := defaultTmuxOptions(index)
tokens := splitRegexp.Split(arg, -1)
errorToReturn := errors.New("invalid tmux option: " + arg + " (expected: [center|top|bottom|left|right][,SIZE[%]][,SIZE[%][,border-native]])")
errorToReturn := errors.New("invalid popup option: " + arg + " (expected: [center|top|bottom|left|right][,SIZE[%]][,SIZE[%][,border-native]])")
if len(tokens) == 0 || len(tokens) > 4 {
return nil, errorToReturn
}
@@ -485,7 +519,7 @@ func parseLabelPosition(opts *labelOpts, arg string) error {
}
func (a previewOpts) aboveOrBelow() bool {
return a.size.size > 0 && (a.position == posUp || a.position == posDown)
return a.size.size > 0 && (a.position == posUp || a.position == posDown || a.position == posNext)
}
type previewOptsCompare int
@@ -523,7 +557,7 @@ func (o *previewOpts) compare(active *previewOpts, b *previewOpts) previewOptsCo
return previewOptsDifferentLayout
}
if a.wrap == b.wrap && a.headerLines == b.headerLines && a.info == b.info && a.scroll == b.scroll {
if a.wrap == b.wrap && a.wrapWord == b.wrapWord && a.headerLines == b.headerLines && a.info == b.info && a.scroll == b.scroll {
return previewOptsSame
}
@@ -552,6 +586,7 @@ type Options struct {
Bash bool
Zsh bool
Fish bool
Nushell bool
Man bool
Fuzzy bool
FuzzyAlgo algo.Algo
@@ -562,12 +597,16 @@ type Options struct {
Case Case
Normalize bool
Nth []Range
FreezeLeft int
FreezeRight int
WithNth func(Delimiter) func([]Token, int32) string
WithNthExpr string
AcceptNth func(Delimiter) func([]Token, int32) string
Delimiter Delimiter
Sort int
Raw bool
Track trackOption
IdNth []Range
Tac bool
Tail int
Criteria []criterion
@@ -583,7 +622,9 @@ type Options struct {
Layout layoutType
Cycle bool
Wrap bool
WrapWord bool
WrapSign *string
PreviewWrapSign *string
MultiLine bool
CursorLine bool
KeepRight bool
@@ -651,6 +692,8 @@ type Options struct {
WalkerSkip []string
Version bool
Help bool
Threads int
Bench time.Duration
CPUProfile string
MEMProfile string
BlockProfile string
@@ -669,7 +712,13 @@ func filterNonEmpty(input []string) []string {
}
func defaultPreviewOpts(command string) previewOpts {
return previewOpts{command, posRight, sizeSpec{50, true}, "", false, false, false, false, true, defaultBorderShape, 0, 0, nil}
return previewOpts{
command: command,
position: posRight,
size: sizeSpec{50, true},
info: true,
border: defaultBorderShape,
}
}
func defaultOptions() *Options {
@@ -685,6 +734,7 @@ func defaultOptions() *Options {
Bash: false,
Zsh: false,
Fish: false,
Nushell: false,
Man: false,
Fuzzy: true,
FuzzyAlgo: algo.FuzzyMatchV2,
@@ -711,6 +761,7 @@ func defaultOptions() *Options {
Layout: layoutDefault,
Cycle: false,
Wrap: false,
WrapWord: false,
MultiLine: true,
KeepRight: false,
Hscroll: true,
@@ -830,7 +881,7 @@ func nthTransformer(str string) (func(Delimiter) func([]Token, int32) string, er
nth []Range
}
parts := make([]NthParts, len(indexes))
parts := make([]NthParts, 0, len(indexes))
idx := 0
for _, index := range indexes {
if idx < index[0] {
@@ -913,6 +964,8 @@ func parseBorder(str string, optional bool) (tui.BorderShape, error) {
switch str {
case "line":
return tui.BorderLine, nil
case "inline":
return tui.BorderInline, nil
case "rounded":
return tui.BorderRounded, nil
case "sharp":
@@ -925,6 +978,8 @@ func parseBorder(str string, optional bool) (tui.BorderShape, error) {
return tui.BorderThinBlock, nil
case "double":
return tui.BorderDouble, nil
case "dashed":
return tui.BorderDashed, nil
case "horizontal":
return tui.BorderHorizontal, nil
case "vertical":
@@ -943,7 +998,7 @@ func parseBorder(str string, optional bool) (tui.BorderShape, error) {
if optional && str == "" {
return defaultBorderShape, nil
}
return tui.BorderNone, errors.New("invalid border style (expected: rounded|sharp|bold|block|thinblock|double|horizontal|vertical|top|bottom|left|right|none)")
return tui.BorderNone, errors.New("invalid border style (expected: rounded|sharp|bold|block|thinblock|double|dashed|horizontal|vertical|top|bottom|left|right|line|inline|none)")
}
func parseKeyChords(str string, message string) (map[tui.Event]string, []tui.Event, error) {
@@ -1212,12 +1267,28 @@ func parseKeyChords(str string, message string) (map[tui.Event]string, []tui.Eve
add(tui.F12)
default:
runes := []rune(key)
if len(key) == 10 && strings.HasPrefix(lkey, "ctrl-alt-") && isAlphabet(lkey[9]) {
evt := tui.CtrlAltKey(rune(key[9]))
if strings.HasPrefix(lkey, "every(") && strings.HasSuffix(lkey, ")") {
evt, err := parseEveryEvent(key[6 : len(key)-1])
if err != nil {
return nil, list, err
}
chords[evt] = key
list = append(list, evt)
} else if len(key) == 10 && strings.HasPrefix(lkey, "ctrl-alt-") && isAlphabet(lkey[9]) {
r := rune(lkey[9])
evt := tui.CtrlAltKey(r)
if r == 'h' && !util.IsWindows() {
evt = tui.CtrlAltBackspace.AsEvent()
}
chords[evt] = key
list = append(list, evt)
} else if len(key) == 6 && strings.HasPrefix(lkey, "ctrl-") && isAlphabet(lkey[5]) {
add(tui.EventType(tui.CtrlA.Int() + int(lkey[5]) - 'a'))
evt := tui.EventType(tui.CtrlA.Int() + int(lkey[5]) - 'a')
r := rune(lkey[5])
if r == 'h' && !util.IsWindows() {
evt = tui.CtrlBackspace
}
add(evt)
} else if len(runes) == 5 && strings.HasPrefix(lkey, "alt-") {
r := runes[4]
switch r {
@@ -1245,6 +1316,21 @@ func parseKeyChords(str string, message string) (map[tui.Event]string, []tui.Eve
return chords, list, nil
}
func parseEveryEvent(arg string) (tui.Event, error) {
secs, err := strconv.ParseFloat(strings.TrimSpace(arg), 64)
if err != nil || math.IsNaN(secs) || math.IsInf(secs, 0) || secs <= 0 {
return tui.Event{}, errors.New("every() requires a positive number of seconds")
}
if secs < 0.01 {
secs = 0.01
}
ms := math.Round(secs * 1000)
if ms > math.MaxInt32 {
return tui.Event{}, errors.New("every() interval is too large")
}
return tui.Event{Type: tui.Every, Char: rune(int32(ms))}, nil
}
func parseScheme(str string) (string, []criterion, error) {
str = strings.ToLower(str)
switch str {
@@ -1376,6 +1462,14 @@ func parseTheme(defaultTheme *tui.ColorTheme, str string) (*tui.ColorTheme, *tui
cattr.Attr |= tui.Italic
case "underline":
cattr.Attr |= tui.Underline
case "underline-double":
cattr.Attr |= tui.Underline | tui.UlStyleDouble
case "underline-curly":
cattr.Attr |= tui.Underline | tui.UlStyleCurly
case "underline-dotted":
cattr.Attr |= tui.Underline | tui.UlStyleDotted
case "underline-dashed":
cattr.Attr |= tui.Underline | tui.UlStyleDashed
case "blink":
cattr.Attr |= tui.Blink
case "reverse":
@@ -1463,6 +1557,8 @@ func parseTheme(defaultTheme *tui.ColorTheme, str string) (*tui.ColorTheme, *tui
mergeAttr(&theme.Nomatch)
case "gutter":
mergeAttr(&theme.Gutter)
case "alt-gutter":
mergeAttr(&theme.AltGutter)
case "hl":
mergeAttr(&theme.Match)
case "current-hl", "hl+":
@@ -1508,7 +1604,7 @@ func parseTheme(defaultTheme *tui.ColorTheme, str string) (*tui.ColorTheme, *tui
case "info":
mergeAttr(&theme.Info)
case "pointer":
mergeAttr(&theme.Cursor)
mergeAttr(&theme.Pointer)
case "marker":
mergeAttr(&theme.Marker)
case "header", "header-fg":
@@ -1554,7 +1650,7 @@ func parseWalkerOpts(str string) (walkerOpts, error) {
}
var (
executeRegexp *regexp.Regexp
argActionRegexp *regexp.Regexp
splitRegexp *regexp.Regexp
actionNameRegexp *regexp.Regexp
)
@@ -1573,8 +1669,8 @@ const (
)
func init() {
executeRegexp = regexp.MustCompile(
`(?si)[:+](become|execute(?:-multi|-silent)?|reload(?:-sync)?|preview|(?:change|bg-transform|transform)-(?:query|prompt|(?:border|list|preview|input|header|footer)-label|header|footer|search|nth|pointer|ghost)|bg-transform|transform|change-(?:preview-window|preview|multi)|(?:re|un|toggle-)bind|pos|put|print|search|trigger)`)
argActionRegexp = regexp.MustCompile(
`(?si)[:+](become|execute(?:-multi|-silent)?|reload(?:-sync)?|preview|(?:change|bg-transform|transform)-(?:query|prompt|(?:border|list|preview|input|header|footer)-label|header-lines|header|footer|search|with-nth|nth|pointer|ghost)|bg-transform|transform|change-(?:preview-window|preview|multi)|(?:re|un|toggle-)bind|pos|put|print|search|trigger)`)
splitRegexp = regexp.MustCompile("[,:]+")
actionNameRegexp = regexp.MustCompile("(?i)^[a-z-]+")
}
@@ -1583,7 +1679,7 @@ func maskActionContents(action string) string {
masked := ""
Loop:
for len(action) > 0 {
loc := executeRegexp.FindStringIndex(action)
loc := argActionRegexp.FindStringIndex(action)
if loc == nil {
masked += action
break
@@ -1637,10 +1733,10 @@ Loop:
return masked
}
func parseSingleActionList(str string) ([]*action, error) {
// We prepend a colon to satisfy executeRegexp and remove it later
func parseSingleActionList(str string, putAllowed bool) ([]*action, error) {
// We prepend a colon to satisfy argActionRegexp and remove it later
masked := maskActionContents(":" + str)[1:]
return parseActionList(masked, str, []*action{}, false)
return parseActionList(masked, str, []*action{}, putAllowed)
}
func parseActionList(masked string, original string, prevActions []*action, putAllowed bool) ([]*action, error) {
@@ -1756,6 +1852,8 @@ func parseActionList(masked string, original string, prevActions []*action, putA
appendAction(actToggleHeader)
case "toggle-wrap":
appendAction(actToggleWrap)
case "toggle-wrap-word":
appendAction(actToggleWrapWord)
case "toggle-multi-line":
appendAction(actToggleMultiLine)
case "toggle-hscroll":
@@ -1822,6 +1920,8 @@ func parseActionList(masked string, original string, prevActions []*action, putA
appendAction(actTogglePreview)
case "toggle-preview-wrap":
appendAction(actTogglePreviewWrap)
case "toggle-preview-wrap-word":
appendAction(actTogglePreviewWrapWord)
case "toggle-sort":
appendAction(actToggleSort)
case "offset-up":
@@ -1942,8 +2042,7 @@ func parseKeymap(keymap map[tui.Event][]*action, str string) error {
}
key = firstKey(keys)
}
putAllowed := key.Type == tui.Rune && unicode.IsGraphic(key.Char)
keymap[key], err = parseActionList(pair[1], origPairStr[len(pair[0])+1:], keymap[key], putAllowed)
keymap[key], err = parseActionList(pair[1], origPairStr[len(pair[0])+1:], keymap[key], key.Printable())
if err != nil {
return err
}
@@ -1981,6 +2080,8 @@ func isExecuteAction(str string) actionType {
return actPreview
case "change-header":
return actChangeHeader
case "change-header-lines":
return actChangeHeaderLines
case "change-footer":
return actChangeFooter
case "change-list-label":
@@ -2011,6 +2112,8 @@ func isExecuteAction(str string) actionType {
return actChangeMulti
case "change-nth":
return actChangeNth
case "change-with-nth":
return actChangeWithNth
case "pos":
return actPosition
case "execute":
@@ -2041,10 +2144,14 @@ func isExecuteAction(str string) actionType {
return actTransformFooter
case "transform-header":
return actTransformHeader
case "transform-header-lines":
return actTransformHeaderLines
case "transform-ghost":
return actTransformGhost
case "transform-nth":
return actTransformNth
case "transform-with-nth":
return actTransformWithNth
case "transform-pointer":
return actTransformPointer
case "transform-prompt":
@@ -2071,10 +2178,14 @@ func isExecuteAction(str string) actionType {
return actBgTransformFooter
case "bg-transform-header":
return actBgTransformHeader
case "bg-transform-header-lines":
return actBgTransformHeaderLines
case "bg-transform-ghost":
return actBgTransformGhost
case "bg-transform-nth":
return actBgTransformNth
case "bg-transform-with-nth":
return actBgTransformWithNth
case "bg-transform-pointer":
return actBgTransformPointer
case "bg-transform-prompt":
@@ -2147,9 +2258,6 @@ func parseHeight(str string, index int) (heightSpec, error) {
str = str[1:]
}
if strings.HasPrefix(str, "-") {
if heightSpec.auto {
return heightSpec, errors.New("negative(-) height is not compatible with adaptive(~) height")
}
heightSpec.inverse = true
str = str[1:]
}
@@ -2233,8 +2341,13 @@ func parsePreviewWindowImpl(opts *previewOpts, input string) error {
opts.hidden = false
case "wrap":
opts.wrap = true
opts.wrapWord = false
case "wrap-word":
opts.wrap = true
opts.wrapWord = true
case "nowrap":
opts.wrap = false
opts.wrapWord = false
case "cycle":
opts.cycle = true
case "nocycle":
@@ -2247,6 +2360,8 @@ func parsePreviewWindowImpl(opts *previewOpts, input string) error {
opts.position = posLeft
case "right":
opts.position = posRight
case "next":
opts.position = posNext
case "rounded", "border", "border-rounded":
opts.border = tui.BorderRounded
case "border-line":
@@ -2261,6 +2376,8 @@ func parsePreviewWindowImpl(opts *previewOpts, input string) error {
opts.border = tui.BorderThinBlock
case "border-double":
opts.border = tui.BorderDouble
case "border-dashed":
opts.border = tui.BorderDashed
case "noborder", "border-none":
opts.border = tui.BorderNone
case "border-horizontal":
@@ -2437,6 +2554,7 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
opts.Bash = false
opts.Zsh = false
opts.Fish = false
opts.Nushell = false
opts.Help = false
opts.Version = false
opts.Man = false
@@ -2549,6 +2667,9 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
case "--fish":
clearExitingOpts()
opts.Fish = true
case "--nushell":
clearExitingOpts()
opts.Nushell = true
case "-h", "--help":
clearExitingOpts()
opts.Help = true
@@ -2557,7 +2678,7 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
opts.Version = true
case "--no-winpty":
opts.NoWinpty = true
case "--tmux":
case "--tmux", "--popup":
given, str := optionalNextString()
if given {
if opts.Tmux, err = parseTmuxOptions(str, index); err != nil {
@@ -2566,7 +2687,7 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
} else {
opts.Tmux = defaultTmuxOptions(index)
}
case "--no-tmux":
case "--no-tmux", "--no-popup":
opts.Tmux = nil
case "--tty-default":
if opts.TtyDefault, err = nextString("tty device name required"); err != nil {
@@ -2695,6 +2816,14 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
if opts.Nth, err = splitNth(str); err != nil {
return err
}
case "--freeze-left":
if opts.FreezeLeft, err = nextInt("number of fields required"); err != nil {
return err
}
case "--freeze-right":
if opts.FreezeRight, err = nextInt("number of fields required"); err != nil {
return err
}
case "--with-nth":
str, err := nextString("nth expression required")
if err != nil {
@@ -2703,6 +2832,7 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
if opts.WithNth, err = nthTransformer(str); err != nil {
return err
}
opts.WithNthExpr = str
case "--accept-nth":
str, err := nextString("nth expression required")
if err != nil {
@@ -2725,6 +2855,16 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
opts.Track = trackEnabled
case "--no-track":
opts.Track = trackDisabled
case "--id-nth":
str, err := nextString("nth expression required")
if err != nil {
return err
}
if opts.IdNth, err = splitNth(str); err != nil {
return err
}
case "--no-id-nth":
opts.IdNth = nil
case "--tac":
opts.Tac = true
case "--no-tac":
@@ -2790,9 +2930,29 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
case "--no-cycle":
opts.Cycle = false
case "--wrap":
opts.Wrap = true
given, str := optionalNextString()
if given {
switch str {
case "char":
opts.Wrap = true
opts.WrapWord = false
case "word":
opts.Wrap = true
opts.WrapWord = true
default:
return errors.New("invalid wrap mode: " + str + " (expected: char or word)")
}
} else {
opts.Wrap = true
}
case "--no-wrap":
opts.Wrap = false
opts.WrapWord = false
case "--wrap-word":
opts.Wrap = true
opts.WrapWord = true
case "--no-wrap-word":
opts.WrapWord = false
case "--wrap-sign":
str, err := nextString("wrap sign required")
if err != nil {
@@ -3012,7 +3172,7 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
case "--no-preview":
opts.Preview.command = ""
case "--preview-window":
str, err := nextString("preview window layout required: [up|down|left|right][,SIZE[%]][,border-STYLE][,wrap][,cycle][,hidden][,+SCROLL[OFFSETS][/DENOM]][,~HEADER_LINES][,default]")
str, err := nextString("preview window layout required: [up|down|left|right|next][,SIZE[%]][,border-STYLE][,wrap][,cycle][,hidden][,+SCROLL[OFFSETS][/DENOM]][,~HEADER_LINES][,default]")
if err != nil {
return err
}
@@ -3026,8 +3186,14 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
if opts.Preview.border, err = parseBorder(arg, !hasArg); err != nil {
return err
}
case "--preview-wrap-sign":
str, err := nextString("preview wrap sign required")
if err != nil {
return err
}
opts.PreviewWrapSign = &str
case "--height":
str, err := nextString("height required: [~]HEIGHT[%]")
str, err := nextString("height required: [~][-]HEIGHT[%]")
if err != nil {
return err
}
@@ -3272,6 +3438,23 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
return err
}
opts.WalkerSkip = filterNonEmpty(strings.Split(str, ","))
case "--threads":
if opts.Threads, err = nextInt("number of threads required"); err != nil {
return err
}
if opts.Threads < 0 {
return errors.New("--threads must be a positive integer")
}
case "--bench":
str, err := nextString("duration required (e.g. 3s, 500ms)")
if err != nil {
return err
}
dur, err := time.ParseDuration(str)
if err != nil {
return errors.New("invalid duration for --bench: " + str)
}
opts.Bench = dur
case "--profile-cpu":
if opts.CPUProfile, err = nextString("file path required: cpu"); err != nil {
return err
@@ -3338,6 +3521,10 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
return errors.New("empty jump labels")
}
if opts.FreezeLeft < 0 || opts.FreezeRight < 0 {
return errors.New("number of fields to freeze must be a non-negative integer")
}
if validateJumpLabels {
for _, r := range opts.JumpLabels {
if r < 32 || r > 126 {
@@ -3373,7 +3560,9 @@ func applyPreset(opts *Options, preset string) error {
opts.Preview.border = tui.BorderLine
opts.Preview.info = false
opts.InfoStyle = infoDefault
opts.Theme.Gutter = tui.ColorAttr{Color: -1, Attr: 0}
opts.Theme.Gutter = tui.NewColorAttr()
space := " "
opts.Gutter = &space
empty := ""
opts.Separator = &empty
opts.Scrollbar = &empty
@@ -3448,7 +3637,7 @@ func validateOptions(opts *Options) error {
}
}
if opts.Height.auto {
if opts.Height.auto && (opts.Tmux == nil || opts.Tmux.index < opts.Height.index) {
for _, s := range []sizeSpec{opts.Margin[0], opts.Margin[2]} {
if s.percent {
return errors.New("adaptive height is not compatible with top/bottom percent margin")
@@ -3465,6 +3654,19 @@ func validateOptions(opts *Options) error {
return errors.New("only ANSI attributes are allowed for 'nth' (regular, bold, underline, reverse, dim, italic, strikethrough)")
}
if opts.BorderShape == tui.BorderInline ||
opts.ListBorderShape == tui.BorderInline ||
opts.InputBorderShape == tui.BorderInline ||
opts.Preview.border == tui.BorderInline {
return errors.New("inline border is only supported for --header-border, --header-lines-border, and --footer-border")
}
if opts.HeaderBorderShape == tui.BorderInline &&
opts.HeaderLinesShape != tui.BorderInline &&
opts.HeaderLinesShape != tui.BorderUndefined &&
opts.HeaderLinesShape != tui.BorderNone {
return errors.New("--header-border=inline requires --header-lines-border to be inline or unset")
}
return nil
}
@@ -3482,6 +3684,10 @@ func (opts *Options) useTmux() bool {
return opts.Tmux != nil && len(os.Getenv("TMUX")) > 0 && opts.Tmux.index >= opts.Height.index
}
func (opts *Options) useZellij() bool {
return opts.Tmux != nil && len(os.Getenv("ZELLIJ")) > 0 && opts.Tmux.index >= opts.Height.index
}
func (opts *Options) noSeparatorLine() bool {
if opts.Inputless {
return true
+93 -2
View File
@@ -299,6 +299,39 @@ func TestBind(t *testing.T) {
check(tui.F1.AsEvent(), "", actAbort)
}
func TestParseEveryEvent(t *testing.T) {
pairs, _, err := parseKeyChords("every(2),every(0.5)", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(pairs) != 2 {
t.Errorf("expected 2 distinct every events, got %d", len(pairs))
}
if pairs[(tui.Event{Type: tui.Every, Char: 2000})] != "every(2)" {
t.Errorf("every(2) not registered")
}
if pairs[(tui.Event{Type: tui.Every, Char: 500})] != "every(0.5)" {
t.Errorf("every(0.5) not registered")
}
// Floor at 0.01s -> 10ms
pairs, _, err = parseKeyChords("every(0.001)", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if pairs[(tui.Event{Type: tui.Every, Char: 10})] != "every(0.001)" {
t.Errorf("every(0.001) should floor to 10ms")
}
// Reject zero, negatives, and overflow (>= 2^31 ms = ~24.85 days)
for _, bad := range []string{"every(0)", "every(-1)", "every(abc)", "every()", "every(2147484)"} {
if _, _, err := parseKeyChords(bad, ""); err == nil {
t.Errorf("%s should be rejected", bad)
}
}
}
func TestColorSpec(t *testing.T) {
var base *tui.ColorTheme
theme := tui.Dark256
@@ -448,6 +481,64 @@ func TestPreviewOpts(t *testing.T) {
opts.Preview.size.size == 70) {
t.Error(opts.Preview)
}
// wrap-word tests
opts = optsFor("--preview-window=wrap-word")
if !(opts.Preview.wrap == true && opts.Preview.wrapWord == true) {
t.Errorf("wrap-word: wrap=%v, wrapWord=%v", opts.Preview.wrap, opts.Preview.wrapWord)
}
opts = optsFor("--preview-window=wrap-word,nowrap")
if !(opts.Preview.wrap == false && opts.Preview.wrapWord == false) {
t.Errorf("wrap-word,nowrap: wrap=%v, wrapWord=%v", opts.Preview.wrap, opts.Preview.wrapWord)
}
opts = optsFor("--preview-window=wrap-word,wrap")
if !(opts.Preview.wrap == true && opts.Preview.wrapWord == false) {
t.Errorf("wrap-word,wrap: wrap=%v, wrapWord=%v", opts.Preview.wrap, opts.Preview.wrapWord)
}
}
func TestPreviewWrapSign(t *testing.T) {
// Default: no preview wrap sign override
opts := optsFor()
if opts.PreviewWrapSign != nil {
t.Errorf("expected nil PreviewWrapSign, got %v", *opts.PreviewWrapSign)
}
// --preview-wrap-sign sets PreviewWrapSign
opts = optsFor("--preview-wrap-sign", ">> ")
if opts.PreviewWrapSign == nil || *opts.PreviewWrapSign != ">> " {
t.Errorf("expected '>> ', got %v", opts.PreviewWrapSign)
}
// --preview-wrap-sign is independent of --wrap-sign
opts = optsFor("--wrap-sign", "| ", "--preview-wrap-sign", ">> ")
if opts.WrapSign == nil || *opts.WrapSign != "| " {
t.Errorf("expected WrapSign '| ', got %v", opts.WrapSign)
}
if opts.PreviewWrapSign == nil || *opts.PreviewWrapSign != ">> " {
t.Errorf("expected PreviewWrapSign '>> ', got %v", opts.PreviewWrapSign)
}
// --preview-wrap-sign without --wrap-sign
opts = optsFor("--preview-wrap-sign", "→ ")
if opts.WrapSign != nil {
t.Errorf("expected nil WrapSign, got %v", *opts.WrapSign)
}
if opts.PreviewWrapSign == nil || *opts.PreviewWrapSign != "→ " {
t.Errorf("expected PreviewWrapSign '→ ', got %v", opts.PreviewWrapSign)
}
// Last --preview-wrap-sign wins
opts = optsFor("--preview-wrap-sign", "A ", "--preview-wrap-sign", "B ")
if opts.PreviewWrapSign == nil || *opts.PreviewWrapSign != "B " {
t.Errorf("expected PreviewWrapSign 'B ', got %v", opts.PreviewWrapSign)
}
// Empty string is allowed
opts = optsFor("--preview-wrap-sign", "")
if opts.PreviewWrapSign == nil || *opts.PreviewWrapSign != "" {
t.Errorf("expected empty PreviewWrapSign, got %v", opts.PreviewWrapSign)
}
}
func TestAdditiveExpect(t *testing.T) {
@@ -481,7 +572,7 @@ func TestValidateSign(t *testing.T) {
}
func TestParseSingleActionList(t *testing.T) {
actions, _ := parseSingleActionList("Execute@foo+bar,baz@+up+up+reload:down+down")
actions, _ := parseSingleActionList("Execute@foo+bar,baz@+up+up+reload:down+down", false)
if len(actions) != 4 {
t.Errorf("Invalid number of actions parsed:%d", len(actions))
}
@@ -497,7 +588,7 @@ func TestParseSingleActionList(t *testing.T) {
}
func TestParseSingleActionListError(t *testing.T) {
_, err := parseSingleActionList("change-query(foobar)baz")
_, err := parseSingleActionList("change-query(foobar)baz", false)
if err == nil {
t.Errorf("Failed to detect error")
}
+91 -50
View File
@@ -61,9 +61,12 @@ type Pattern struct {
delimiter Delimiter
nth []Range
revision revision
procFun map[termType]algo.Algo
procFun [6]algo.Algo
cache *ChunkCache
denylist map[int32]struct{}
startIndex int32
directAlgo algo.Algo
directTerm *term
}
var _splitRegex *regexp.Regexp
@@ -74,7 +77,7 @@ func init() {
// BuildPattern builds Pattern object from the given arguments
func BuildPattern(cache *ChunkCache, patternCache map[string]*Pattern, fuzzy bool, fuzzyAlgo algo.Algo, extended bool, caseMode Case, normalize bool, forward bool,
withPos bool, cacheable bool, nth []Range, delimiter Delimiter, revision revision, runes []rune, denylist map[int32]struct{}) *Pattern {
withPos bool, cacheable bool, nth []Range, delimiter Delimiter, revision revision, runes []rune, denylist map[int32]struct{}, startIndex int32) *Pattern {
var asString string
if extended {
@@ -146,9 +149,11 @@ func BuildPattern(cache *ChunkCache, patternCache map[string]*Pattern, fuzzy boo
delimiter: delimiter,
cache: cache,
denylist: denylist,
procFun: make(map[termType]algo.Algo)}
startIndex: startIndex,
}
ptr.cacheKey = ptr.buildCacheKey()
ptr.directAlgo, ptr.directTerm = ptr.buildDirectAlgo(fuzzyAlgo)
ptr.procFun[termFuzzy] = fuzzyAlgo
ptr.procFun[termEqual] = algo.EqualMatch
ptr.procFun[termExact] = algo.ExactMatchNaive
@@ -272,6 +277,22 @@ func (p *Pattern) buildCacheKey() string {
return strings.Join(cacheableTerms, "\t")
}
// buildDirectAlgo returns the algo function and term for the direct fast path
// in matchChunk. Returns (nil, nil) if the pattern is not suitable.
// Requirements: extended mode, single term set with single non-inverse fuzzy term, no nth.
func (p *Pattern) buildDirectAlgo(fuzzyAlgo algo.Algo) (algo.Algo, *term) {
if !p.extended || len(p.nth) > 0 {
return nil, nil
}
if len(p.termSets) == 1 && len(p.termSets[0]) == 1 {
t := &p.termSets[0][0]
if !t.inv && t.typ == termFuzzy {
return fuzzyAlgo, t
}
}
return nil, nil
}
// CacheKey is used to build string to be used as the key of result cache
func (p *Pattern) CacheKey() string {
return p.cacheKey
@@ -279,84 +300,104 @@ func (p *Pattern) CacheKey() string {
// Match returns the list of matches Items in the given Chunk
func (p *Pattern) Match(chunk *Chunk, slab *util.Slab) []Result {
// ChunkCache: Exact match
cacheKey := p.CacheKey()
// Bitmap cache: exact match or prefix/suffix
var cachedBitmap *ChunkBitmap
if p.cacheable {
if cached := p.cache.Lookup(chunk, cacheKey); cached != nil {
return cached
}
cachedBitmap = p.cache.Lookup(chunk, cacheKey)
}
if cachedBitmap == nil {
cachedBitmap = p.cache.Search(chunk, cacheKey)
}
// Prefix/suffix cache
space := p.cache.Search(chunk, cacheKey)
matches := p.matchChunk(chunk, space, slab)
matches, bitmap := p.matchChunk(chunk, cachedBitmap, slab)
if p.cacheable {
p.cache.Add(chunk, cacheKey, matches)
p.cache.Add(chunk, cacheKey, bitmap, len(matches))
}
return matches
}
func (p *Pattern) matchChunk(chunk *Chunk, space []Result, slab *util.Slab) []Result {
func (p *Pattern) matchChunk(chunk *Chunk, cachedBitmap *ChunkBitmap, slab *util.Slab) ([]Result, ChunkBitmap) {
matches := []Result{}
var bitmap ChunkBitmap
// Skip header items in chunks that contain them
startIdx := 0
if p.startIndex > 0 && chunk.count > 0 && chunk.items[0].Index() < p.startIndex {
startIdx = int(p.startIndex - chunk.items[0].Index())
if startIdx >= chunk.count {
return matches, bitmap
}
}
hasCachedBitmap := cachedBitmap != nil
// Fast path: single fuzzy term, no nth, no denylist.
// Calls the algo function directly, bypassing MatchItem/extendedMatch/iter
// and avoiding per-match []Offset heap allocation.
if p.directAlgo != nil && len(p.denylist) == 0 {
t := p.directTerm
for idx := startIdx; idx < chunk.count; idx++ {
if hasCachedBitmap && cachedBitmap[idx/64]&(uint64(1)<<(idx%64)) == 0 {
continue
}
res, _ := p.directAlgo(t.caseSensitive, t.normalize, p.forward,
&chunk.items[idx].text, t.text, p.withPos, slab)
if res.Start >= 0 {
bitmap[idx/64] |= uint64(1) << (idx % 64)
matches = append(matches, buildResultFromBounds(
&chunk.items[idx], res.Score,
int(res.Start), int(res.End), int(res.End), true))
}
}
return matches, bitmap
}
if len(p.denylist) == 0 {
// Huge code duplication for minimizing unnecessary map lookups
if space == nil {
for idx := 0; idx < chunk.count; idx++ {
if match, _, _ := p.MatchItem(&chunk.items[idx], p.withPos, slab); match != nil {
matches = append(matches, *match)
}
for idx := startIdx; idx < chunk.count; idx++ {
if hasCachedBitmap && cachedBitmap[idx/64]&(uint64(1)<<(idx%64)) == 0 {
continue
}
} else {
for _, result := range space {
if match, _, _ := p.MatchItem(result.item, p.withPos, slab); match != nil {
matches = append(matches, *match)
}
if match, _, _ := p.MatchItem(&chunk.items[idx], p.withPos, slab); match.item != nil {
bitmap[idx/64] |= uint64(1) << (idx % 64)
matches = append(matches, match)
}
}
return matches
return matches, bitmap
}
if space == nil {
for idx := 0; idx < chunk.count; idx++ {
if _, prs := p.denylist[chunk.items[idx].Index()]; prs {
continue
}
if match, _, _ := p.MatchItem(&chunk.items[idx], p.withPos, slab); match != nil {
matches = append(matches, *match)
}
for idx := startIdx; idx < chunk.count; idx++ {
if hasCachedBitmap && cachedBitmap[idx/64]&(uint64(1)<<(idx%64)) == 0 {
continue
}
} else {
for _, result := range space {
if _, prs := p.denylist[result.item.Index()]; prs {
continue
}
if match, _, _ := p.MatchItem(result.item, p.withPos, slab); match != nil {
matches = append(matches, *match)
}
if _, prs := p.denylist[chunk.items[idx].Index()]; prs {
continue
}
if match, _, _ := p.MatchItem(&chunk.items[idx], p.withPos, slab); match.item != nil {
bitmap[idx/64] |= uint64(1) << (idx % 64)
matches = append(matches, match)
}
}
return matches
return matches, bitmap
}
// MatchItem returns true if the Item is a match
func (p *Pattern) MatchItem(item *Item, withPos bool, slab *util.Slab) (*Result, []Offset, *[]int) {
// MatchItem returns the match result if the Item is a match.
// A zero-value Result (with item == nil) indicates no match.
func (p *Pattern) MatchItem(item *Item, withPos bool, slab *util.Slab) (Result, []Offset, *[]int) {
if p.extended {
if offsets, bonus, pos := p.extendedMatch(item, withPos, slab); len(offsets) == len(p.termSets) {
result := buildResult(item, offsets, bonus)
return &result, offsets, pos
return buildResult(item, offsets, bonus), offsets, pos
}
return nil, nil, nil
return Result{}, nil, nil
}
offset, bonus, pos := p.basicMatch(item, withPos, slab)
if sidx := offset[0]; sidx >= 0 {
offsets := []Offset{offset}
result := buildResult(item, offsets, bonus)
return &result, offsets, pos
return buildResult(item, offsets, bonus), offsets, pos
}
return nil, nil, nil
return Result{}, nil, nil
}
func (p *Pattern) basicMatch(item *Item, withPos bool, slab *util.Slab) (Offset, int, *[]int) {
+119 -2
View File
@@ -2,6 +2,7 @@ package fzf
import (
"reflect"
"runtime"
"testing"
"github.com/junegunn/fzf/src/algo"
@@ -68,7 +69,7 @@ func buildPattern(fuzzy bool, fuzzyAlgo algo.Algo, extended bool, caseMode Case,
withPos bool, cacheable bool, nth []Range, delimiter Delimiter, runes []rune) *Pattern {
return BuildPattern(NewChunkCache(), make(map[string]*Pattern),
fuzzy, fuzzyAlgo, extended, caseMode, normalize, forward,
withPos, cacheable, nth, delimiter, revision{}, runes, nil)
withPos, cacheable, nth, delimiter, revision{}, runes, nil, 0)
}
func TestExact(t *testing.T) {
@@ -137,7 +138,7 @@ func TestOrigTextAndTransformed(t *testing.T) {
origText: &origBytes,
transformed: &transformed{pattern.revision, trans}}
pattern.extended = extended
matches := pattern.matchChunk(&chunk, nil, slab) // No cache
matches, _ := pattern.matchChunk(&chunk, nil, slab) // No cache
if !(matches[0].item.text.ToString() == "junegunn" &&
string(*matches[0].item.origText) == "junegunn.choi" &&
reflect.DeepEqual((*matches[0].item.transformed).tokens, trans)) {
@@ -199,3 +200,119 @@ func TestCacheable(t *testing.T) {
test(false, "foo 'bar", "foo", false)
test(false, "foo !bar", "foo", false)
}
func buildChunks(numChunks int) []*Chunk {
chunks := make([]*Chunk, numChunks)
words := []string{
"src/main/java/com/example/service/UserService.java",
"src/test/java/com/example/service/UserServiceTest.java",
"docs/api/reference/endpoints.md",
"lib/internal/utils/string_helper.go",
"pkg/server/http/handler/auth.go",
"build/output/release/app.exe",
"config/production/database.yml",
"scripts/deploy/kubernetes/setup.sh",
"vendor/github.com/junegunn/fzf/src/core.go",
"node_modules/.cache/babel/transform.js",
}
for ci := range numChunks {
chunks[ci] = &Chunk{count: chunkSize}
for i := range chunkSize {
text := words[(ci*chunkSize+i)%len(words)]
chunks[ci].items[i] = Item{text: util.ToChars([]byte(text))}
chunks[ci].items[i].text.Index = int32(ci*chunkSize + i)
}
}
return chunks
}
func buildPatternWith(cache *ChunkCache, runes []rune) *Pattern {
return BuildPattern(cache, make(map[string]*Pattern),
true, algo.FuzzyMatchV2, true, CaseSmart, false, true,
false, true, []Range{}, Delimiter{}, revision{}, runes, nil, 0)
}
func TestBitmapCacheBenefit(t *testing.T) {
numChunks := 100
chunks := buildChunks(numChunks)
queries := []string{"s", "se", "ser", "serv", "servi"}
// 1. Run all queries with shared cache (simulates incremental typing)
cache := NewChunkCache()
for _, q := range queries {
pat := buildPatternWith(cache, []rune(q))
for _, chunk := range chunks {
pat.Match(chunk, slab)
}
}
// 2. GC and measure memory with cache populated
runtime.GC()
runtime.GC()
var memWith runtime.MemStats
runtime.ReadMemStats(&memWith)
// 3. Clear cache, GC, measure again
cache.Clear()
runtime.GC()
runtime.GC()
var memWithout runtime.MemStats
runtime.ReadMemStats(&memWithout)
cacheMem := int64(memWith.Alloc) - int64(memWithout.Alloc)
t.Logf("Chunks: %d, Queries: %d", numChunks, len(queries))
t.Logf("Cache memory: %d bytes (%.1f KB)", cacheMem, float64(cacheMem)/1024)
t.Logf("Per-chunk-per-query: %.0f bytes", float64(cacheMem)/float64(numChunks*len(queries)))
// 4. Verify correctness: cached vs uncached produce same results
cache2 := NewChunkCache()
for _, q := range queries {
pat := buildPatternWith(cache2, []rune(q))
for _, chunk := range chunks {
pat.Match(chunk, slab)
}
}
for _, q := range queries {
patCached := buildPatternWith(cache2, []rune(q))
patFresh := buildPatternWith(NewChunkCache(), []rune(q))
var countCached, countFresh int
for _, chunk := range chunks {
countCached += len(patCached.Match(chunk, slab))
countFresh += len(patFresh.Match(chunk, slab))
}
if countCached != countFresh {
t.Errorf("query=%q: cached=%d, fresh=%d", q, countCached, countFresh)
}
t.Logf("query=%q: matches=%d", q, countCached)
}
}
func BenchmarkWithCache(b *testing.B) {
numChunks := 100
chunks := buildChunks(numChunks)
queries := []string{"s", "se", "ser", "serv", "servi"}
b.Run("cached", func(b *testing.B) {
for range b.N {
cache := NewChunkCache()
for _, q := range queries {
pat := buildPatternWith(cache, []rune(q))
for _, chunk := range chunks {
pat.Match(chunk, slab)
}
}
}
})
b.Run("uncached", func(b *testing.B) {
for range b.N {
for _, q := range queries {
cache := NewChunkCache()
pat := buildPatternWith(cache, []rune(q))
for _, chunk := range chunks {
pat.Match(chunk, slab)
}
}
}
})
}
+1 -1
View File
@@ -6,5 +6,5 @@ import "golang.org/x/sys/unix"
// Protect calls OS specific protections like pledge on OpenBSD
func Protect() {
unix.PledgePromises("stdio dpath wpath rpath tty proc exec inet tmppath")
unix.PledgePromises("stdio cpath dpath wpath rpath inet fattr unix tty proc exec")
}
+26
View File
@@ -23,6 +23,32 @@ func escapeSingleQuote(str string) string {
return "'" + strings.ReplaceAll(str, "'", "'\\''") + "'"
}
func popupArgStr(args []string, opts *Options) (string, string) {
fzf, rest := args[0], args[1:]
args = []string{"--bind=ctrl-z:ignore"}
if !opts.Tmux.border && (opts.BorderShape == tui.BorderUndefined || opts.BorderShape == tui.BorderLine) {
if tui.DefaultBorderShape == tui.BorderRounded {
rest = append(rest, "--border=rounded")
} else {
rest = append(rest, "--border=sharp")
}
}
if opts.Tmux.border && opts.Margin == defaultMargin() {
args = append(args, "--margin=0,1")
}
argStr := escapeSingleQuote(fzf)
for _, arg := range append(args, rest...) {
argStr += " " + escapeSingleQuote(arg)
}
argStr += ` --no-popup --no-height`
dir, err := os.Getwd()
if err != nil {
dir = "."
}
return argStr, dir
}
func fifo(name string) (string, error) {
ns := time.Now().UnixNano()
output := filepath.Join(os.TempDir(), fmt.Sprintf("fzf-%s-%d", name, ns))
+49 -12
View File
@@ -7,6 +7,7 @@ import (
"io/fs"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"sync/atomic"
@@ -177,8 +178,8 @@ func (r *Reader) feed(src io.Reader) {
var err error
for {
n := 0
scope := slab[:util.Min(len(slab), readerBufferSize)]
for i := 0; i < 100; i++ {
scope := slab[:min(len(slab), readerBufferSize)]
for range 100 {
n, err = src.Read(scope)
if n > 0 || err != nil {
break
@@ -273,6 +274,24 @@ func (r *Reader) readFiles(roots []string, opts walkerOpts, ignores []string) bo
ToSlash: fastwalk.DefaultToSlash(),
Sort: fastwalk.SortFilesFirst,
}
// When following symlinks, precompute the absolute real paths of walker
// roots so we can skip symlinks that point to an ancestor. fastwalk's
// built-in loop detection (shouldTraverse) catches loops on the second
// pass, but a single pass through a symlink like z: -> / already
// traverses the entire root filesystem, causing severe resource
// exhaustion. Skipping ancestor symlinks prevents this entirely.
var absRoots []string
if opts.follow {
for _, root := range roots {
if real, err := filepath.EvalSymlinks(root); err == nil {
if abs, err := filepath.Abs(real); err == nil {
absRoots = append(absRoots, filepath.Clean(abs))
}
}
}
}
ignoresBase := []string{}
ignoresFull := []string{}
ignoresSuffix := []string{}
@@ -302,21 +321,39 @@ func (r *Reader) readFiles(roots []string, opts walkerOpts, ignores []string) bo
}
path = trimPath(path)
if path != "." {
isDir := de.IsDir()
if isDir || opts.follow && isSymlinkToDir(path, de) {
isDirSymlink := isSymlinkToDir(path, de)
if isDirSymlink && !opts.follow {
return filepath.SkipDir
}
// Skip symlinks whose target is an ancestor of (or equal to)
// any walker root. Following such symlinks would traverse a
// superset of the tree we're already walking.
if isDirSymlink && len(absRoots) > 0 {
if target, err := filepath.EvalSymlinks(path); err == nil {
if abs, err := filepath.Abs(target); err == nil {
abs = filepath.Clean(abs)
if abs == string(os.PathSeparator) {
return filepath.SkipDir
}
for _, absRoot := range absRoots {
if absRoot == abs || strings.HasPrefix(absRoot, abs+string(os.PathSeparator)) {
return filepath.SkipDir
}
}
}
}
}
isDir := de.IsDir() || isDirSymlink
if isDir {
base := filepath.Base(path)
if !opts.hidden && base[0] == '.' && base != ".." {
return filepath.SkipDir
}
for _, ignore := range ignoresBase {
if ignore == base {
return filepath.SkipDir
}
if slices.Contains(ignoresBase, base) {
return filepath.SkipDir
}
for _, ignore := range ignoresFull {
if ignore == path {
return filepath.SkipDir
}
if slices.Contains(ignoresFull, path) {
return filepath.SkipDir
}
for _, ignore := range ignoresSuffix {
if strings.HasSuffix(path, ignore) {
+119 -31
View File
@@ -2,6 +2,7 @@ package fzf
import (
"math"
"slices"
"sort"
"unicode"
@@ -30,11 +31,9 @@ type Result struct {
func buildResult(item *Item, offsets []Offset, score int) Result {
if len(offsets) > 1 {
sort.Sort(ByOrder(offsets))
slices.SortFunc(offsets, compareOffsets)
}
result := Result{item: item}
numChars := item.text.Length()
minBegin := math.MaxUint16
minEnd := math.MaxUint16
maxEnd := 0
@@ -42,13 +41,21 @@ func buildResult(item *Item, offsets []Offset, score int) Result {
for _, offset := range offsets {
b, e := int(offset[0]), int(offset[1])
if b < e {
minBegin = util.Min(b, minBegin)
minEnd = util.Min(e, minEnd)
maxEnd = util.Max(e, maxEnd)
minBegin = min(b, minBegin)
minEnd = min(e, minEnd)
maxEnd = max(e, maxEnd)
validOffsetFound = true
}
}
return buildResultFromBounds(item, score, minBegin, minEnd, maxEnd, validOffsetFound)
}
// buildResultFromBounds builds a Result from pre-computed offset bounds.
func buildResultFromBounds(item *Item, score int, minBegin, minEnd, maxEnd int, validOffsetFound bool) Result {
result := Result{item: item}
numChars := item.text.Length()
for idx, criterion := range sortCriteria {
val := uint16(math.MaxUint16)
switch criterion {
@@ -75,7 +82,6 @@ func buildResult(item *Item, offsets []Offset, score int) Result {
val = item.TrimLength()
case byPathname:
if validOffsetFound {
// lastDelim := strings.LastIndexByte(item.text.ToString(), '/')
lastDelim := -1
s := item.text.ToString()
for i := len(s) - 1; i >= 0; i-- {
@@ -91,7 +97,7 @@ func buildResult(item *Item, offsets []Offset, score int) Result {
case byBegin, byEnd:
if validOffsetFound {
whitePrefixLen := 0
for idx := 0; idx < numChars; idx++ {
for idx := range numChars {
r := item.text.Get(idx)
whitePrefixLen = idx
if idx == minBegin || !unicode.IsSpace(r) {
@@ -123,7 +129,7 @@ func minRank() Result {
return Result{item: &minItem, points: [4]uint16{math.MaxUint16, 0, 0, 0}}
}
func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, theme *tui.ColorTheme, colBase tui.ColorPair, colMatch tui.ColorPair, attrNth tui.Attr, hidden bool) []colorOffset {
func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, theme *tui.ColorTheme, colBase tui.ColorPair, colMatch tui.ColorPair, attrNth tui.Attr, nthOverlay tui.Attr, hidden bool) []colorOffset {
itemColors := result.item.Colors()
// No ANSI codes
@@ -182,7 +188,7 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
}
}
// sort.Sort(ByOrder(offsets))
// slices.SortFunc(offsets, compareOffsets)
// Merge offsets
// ------------ ---- -- ----
@@ -192,7 +198,10 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
start := 0
ansiToColorPair := func(ansi ansiOffset, base tui.ColorPair) tui.ColorPair {
if !theme.Colored {
return tui.NewColorPair(-1, -1, ansi.color.attr).MergeAttr(base)
// Ignore ANSI colors but keep the attributes. Retain the base
// colors (e.g. an overridden input-bg or list-bg) instead of
// resetting to the terminal default.
return tui.NewColorPair(base.Fg(), base.Bg(), ansi.color.attr).MergeAttr(base)
}
// fd --color always | fzf --ansi --delimiter / --nth -1 --color fg:dim:strip,nth:regular
if base.ShouldStripColors() {
@@ -206,8 +215,12 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
if bg == -1 {
bg = colBase.Bg()
}
return tui.NewColorPair(fg, bg, ansi.color.attr).MergeAttr(base)
return tui.NewColorPair(fg, bg, ansi.color.attr).WithUl(ansi.color.ul).MergeAttr(base)
}
fgAttr := tui.ColNormal.Attr()
nthAttrFinal := fgAttr.Merge(attrNth).Merge(nthOverlay)
nthBase := colBase.WithNewAttr(nthAttrFinal)
var colors []colorOffset
add := func(idx int) {
if curr.fbg >= 0 {
@@ -221,7 +234,7 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
if curr.match {
var color tui.ColorPair
if curr.nth {
color = colBase.WithAttr(attrNth).Merge(colMatch)
color = nthBase.Merge(colMatch)
} else {
color = colBase.Merge(colMatch)
}
@@ -241,7 +254,7 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
if color.Fg().IsDefault() && origColor.HasBg() {
color = origColor
if curr.nth {
color = color.WithAttr(attrNth &^ tui.AttrRegular)
color = color.WithAttr((attrNth &^ tui.AttrRegular).Merge(nthOverlay))
}
} else {
color = origColor.MergeNonDefault(color)
@@ -253,7 +266,7 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
ansi := itemColors[curr.index]
base := colBase
if curr.nth {
base = base.WithAttr(attrNth)
base = nthBase
}
if hidden {
base = base.WithFg(theme.Nomatch)
@@ -265,7 +278,7 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
match: false,
url: ansi.color.url})
} else {
color := colBase.WithAttr(attrNth)
color := nthBase
if hidden {
color = color.WithFg(theme.Nomatch)
}
@@ -288,21 +301,20 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
return colors
}
// ByOrder is for sorting substring offsets
type ByOrder []Offset
func (a ByOrder) Len() int {
return len(a)
}
func (a ByOrder) Swap(i, j int) {
a[i], a[j] = a[j], a[i]
}
func (a ByOrder) Less(i, j int) bool {
ioff := a[i]
joff := a[j]
return (ioff[0] < joff[0]) || (ioff[0] == joff[0]) && (ioff[1] <= joff[1])
func compareOffsets(a, b Offset) int {
if a[0] < b[0] {
return -1
}
if a[0] > b[0] {
return 1
}
if a[1] < b[1] {
return -1
}
if a[1] > b[1] {
return 1
}
return 0
}
// ByRelevance is for sorting Items
@@ -334,3 +346,79 @@ func (a ByRelevanceTac) Swap(i, j int) {
func (a ByRelevanceTac) Less(i, j int) bool {
return compareRanks(a[i], a[j], true)
}
// radixSortResults sorts Results by their points key using LSD radix sort.
// O(n) time complexity vs O(n log n) for comparison sort.
// The sort is stable, so equal-key items maintain original (item-index) order.
// For tac mode, runs of equal keys are reversed after sorting.
func radixSortResults(a []Result, tac bool, scratch []Result) []Result {
n := len(a)
if n < 128 {
if tac {
sort.Sort(ByRelevanceTac(a))
} else {
sort.Sort(ByRelevance(a))
}
return scratch[:0]
}
if cap(scratch) < n {
scratch = make([]Result, n)
}
buf := scratch[:n]
src, dst := a, buf
scattered := 0
for pass := range 8 {
shift := uint(pass) * 8
var count [256]int
for i := range src {
count[byte(sortKey(&src[i])>>shift)]++
}
// Skip if all items have the same byte value at this position
if count[byte(sortKey(&src[0])>>shift)] == n {
continue
}
var offset [256]int
for i := 1; i < 256; i++ {
offset[i] = offset[i-1] + count[i-1]
}
for i := range src {
b := byte(sortKey(&src[i]) >> shift)
dst[offset[b]] = src[i]
offset[b]++
}
src, dst = dst, src
scattered++
}
// If odd number of scatters, data is in buf, copy back to a
if scattered%2 == 1 {
copy(a, src)
}
// Handle tac: reverse runs of equal keys so equal-key items
// are in reverse item-index order
if tac {
i := 0
for i < n {
ki := sortKey(&a[i])
j := i + 1
for j < n && sortKey(&a[j]) == ki {
j++
}
if j-i > 1 {
for l, r := i, j-1; l < r; l, r = l+1, r-1 {
a[l], a[r] = a[r], a[l]
}
}
i = j
}
}
return scratch
}
+5 -1
View File
@@ -1,4 +1,4 @@
//go:build !386 && !amd64
//go:build !386 && !amd64 && !arm64
package fzf
@@ -14,3 +14,7 @@ func compareRanks(irank Result, jrank Result, tac bool) bool {
}
return (irank.item.Index() <= jrank.item.Index()) != tac
}
func sortKey(r *Result) uint64 {
return uint64(r.points[0]) | uint64(r.points[1])<<16 | uint64(r.points[2])<<32 | uint64(r.points[3])<<48
}
+97 -7
View File
@@ -2,6 +2,8 @@ package fzf
import (
"math"
"math/rand"
"slices"
"sort"
"testing"
@@ -18,7 +20,7 @@ func TestOffsetSort(t *testing.T) {
offsets := []Offset{
{3, 5}, {2, 7},
{1, 3}, {2, 9}}
sort.Sort(ByOrder(offsets))
slices.SortFunc(offsets, compareOffsets)
if offsets[0][0] != 1 || offsets[0][1] != 3 ||
offsets[1][0] != 2 || offsets[1][1] != 7 ||
@@ -124,14 +126,14 @@ func TestColorOffset(t *testing.T) {
item := Result{
item: &Item{
colors: &[]ansiOffset{
{[2]int32{0, 20}, ansiState{1, 5, 0, -1, nil}},
{[2]int32{22, 27}, ansiState{2, 6, tui.Bold, -1, nil}},
{[2]int32{30, 32}, ansiState{3, 7, 0, -1, nil}},
{[2]int32{33, 40}, ansiState{4, 8, tui.Bold, -1, nil}}}}}
{[2]int32{0, 20}, ansiState{1, 5, -1, 0, -1, nil}},
{[2]int32{22, 27}, ansiState{2, 6, -1, tui.Bold, -1, nil}},
{[2]int32{30, 32}, ansiState{3, 7, -1, 0, -1, nil}},
{[2]int32{33, 40}, ansiState{4, 8, -1, tui.Bold, -1, nil}}}}}
colBase := tui.NewColorPair(89, 189, tui.AttrUndefined)
colMatch := tui.NewColorPair(99, 199, tui.AttrUndefined)
colors := item.colorOffsets(offsets, nil, tui.Dark256, colBase, colMatch, tui.AttrUndefined, false)
colors := item.colorOffsets(offsets, nil, tui.Dark256, colBase, colMatch, tui.AttrUndefined, 0, false)
assert := func(idx int, b int32, e int32, c tui.ColorPair) {
o := colors[idx]
if o.offset[0] != b || o.offset[1] != e || o.color != c {
@@ -158,7 +160,7 @@ func TestColorOffset(t *testing.T) {
nthOffsets := []Offset{{37, 39}, {42, 45}}
for _, attr := range []tui.Attr{tui.AttrRegular, tui.StrikeThrough} {
colors = item.colorOffsets(offsets, nthOffsets, tui.Dark256, colRegular, colUnderline, attr, false)
colors = item.colorOffsets(offsets, nthOffsets, tui.Dark256, colRegular, colUnderline, attr, 0, false)
// [{[0 5] {1 5 0}} {[5 15] {1 5 8}} {[15 20] {1 5 0}}
// {[22 25] {2 6 1}} {[25 27] {2 6 9}} {[27 30] {-1 -1 8}}
@@ -181,4 +183,92 @@ func TestColorOffset(t *testing.T) {
assert(10, 37, 39, tui.NewColorPair(4, 8, expected))
assert(11, 39, 40, tui.NewColorPair(4, 8, tui.Bold))
}
// Test nthOverlay: simulates nth:regular with current-fg:underline
// The overlay (underline) should survive even though nth:regular clears attrs.
// Precedence: fg < nth < current-fg
colors = item.colorOffsets(offsets, nthOffsets, tui.Dark256, colRegular, colUnderline, tui.AttrRegular, tui.Underline, false)
// nth regions should have Underline (from overlay), not cleared by AttrRegular
// Non-nth regions keep colBase attrs (AttrUndefined)
assert(0, 0, 5, tui.NewColorPair(1, 5, tui.AttrUndefined))
assert(1, 5, 15, tui.NewColorPair(1, 5, tui.Underline))
assert(2, 15, 20, tui.NewColorPair(1, 5, tui.AttrUndefined))
assert(3, 22, 25, tui.NewColorPair(2, 6, tui.Bold))
assert(4, 25, 27, tui.NewColorPair(2, 6, tui.Bold|tui.Underline))
assert(5, 27, 30, colUnderline)
assert(6, 30, 32, tui.NewColorPair(3, 7, tui.Underline))
assert(7, 32, 33, colUnderline)
assert(8, 33, 35, tui.NewColorPair(4, 8, tui.Bold|tui.Underline))
assert(9, 35, 37, tui.NewColorPair(4, 8, tui.Bold))
// nth region within ANSI bold: AttrRegular clears, overlay adds Underline back
assert(10, 37, 39, tui.NewColorPair(4, 8, tui.Bold|tui.Underline))
assert(11, 39, 40, tui.NewColorPair(4, 8, tui.Bold))
// Test nthOverlay with additive attrs: nth:strikethrough with selected-fg:bold
colors = item.colorOffsets(offsets, nthOffsets, tui.Dark256, colRegular, colUnderline, tui.StrikeThrough, tui.Bold, false)
// Non-nth entries unchanged from overlay=0 case
assert(0, 0, 5, tui.NewColorPair(1, 5, tui.AttrUndefined))
assert(5, 27, 30, colUnderline) // match only, no nth
assert(7, 32, 33, colUnderline) // match only, no nth
// nth region within ANSI bold: StrikeThrough|Bold merged with ANSI Bold
assert(10, 37, 39, tui.NewColorPair(4, 8, tui.Bold|tui.StrikeThrough))
}
func TestRadixSortResults(t *testing.T) {
sortCriteria = []criterion{byScore, byLength}
rng := rand.New(rand.NewSource(42))
for _, n := range []int{128, 256, 500, 1000} {
for _, tac := range []bool{false, true} {
// Build items with random points and indices
items := make([]*Item, n)
for i := range items {
items[i] = &Item{text: util.Chars{Index: int32(i)}}
}
results := make([]Result, n)
for i := range results {
results[i] = Result{
item: items[i],
points: [4]uint16{
uint16(rng.Intn(256)),
uint16(rng.Intn(256)),
uint16(rng.Intn(256)),
uint16(rng.Intn(256)),
},
}
}
// Make some duplicates to test stability
for i := 0; i < n/4; i++ {
j := rng.Intn(n)
k := rng.Intn(n)
results[j].points = results[k].points
}
// Copy for reference sort
expected := make([]Result, n)
copy(expected, results)
if tac {
sort.Sort(ByRelevanceTac(expected))
} else {
sort.Sort(ByRelevance(expected))
}
// Radix sort
var scratch []Result
scratch = radixSortResults(results, tac, scratch)
for i := range results {
if results[i] != expected[i] {
t.Errorf("n=%d tac=%v: mismatch at index %d: got item %d, want item %d",
n, tac, i, results[i].item.Index(), expected[i].item.Index())
break
}
}
}
}
}
+5 -1
View File
@@ -1,4 +1,4 @@
//go:build 386 || amd64
//go:build 386 || amd64 || arm64
package fzf
@@ -14,3 +14,7 @@ func compareRanks(irank Result, jrank Result, tac bool) bool {
}
return (irank.item.Index() <= jrank.item.Index()) != tac
}
func sortKey(r *Result) uint64 {
return *(*uint64)(unsafe.Pointer(&r.points[0]))
}
+29 -22
View File
@@ -122,13 +122,12 @@ func startHttpServer(address listenAddress, actionChannel chan []*action, getHan
}
}
server := httpServer{
apiKey: []byte(apiKey),
actionChannel: actionChannel,
getHandler: getHandler,
}
go func() {
server := httpServer{
apiKey: []byte(apiKey),
actionChannel: actionChannel,
getHandler: getHandler,
}
for {
conn, err := listener.Accept()
if err != nil {
@@ -154,7 +153,7 @@ func startHttpServer(address listenAddress, actionChannel chan []*action, getHan
func (server *httpServer) handleHttpRequest(conn net.Conn) string {
contentLength := 0
apiKey := ""
body := ""
var bodyBuilder strings.Builder
answer := func(code string, message string) string {
message += "\n"
return code + fmt.Sprintf("Content-Length: %d%s", len(message), crlf+crlf+message)
@@ -176,30 +175,29 @@ func (server *httpServer) handleHttpRequest(conn net.Conn) string {
token := data[:found+len(crlf)]
return len(token), token, nil
}
if atEOF || len(body)+len(data) >= contentLength {
if atEOF || bodyBuilder.Len()+len(data) >= contentLength {
return 0, data, bufio.ErrFinalToken
}
return 0, nil, nil
})
section := 0
var getMatch []string
Loop:
for scanner.Scan() {
text := scanner.Text()
switch section {
case 0:
getMatch := getRegex.FindStringSubmatch(text)
if len(getMatch) > 0 {
response := server.getHandler(parseGetParams(getMatch[1]))
if len(response) > 0 {
return good(response)
}
return answer(httpUnavailable+jsonContentType, `{"error":"timeout"}`)
} else if !strings.HasPrefix(text, "POST / HTTP") {
case 0: // Request line
getMatch = getRegex.FindStringSubmatch(text)
if len(getMatch) == 0 && !strings.HasPrefix(text, "POST / HTTP") {
return bad("invalid request method")
}
section++
case 1:
if text == crlf {
case 1: // Request headers
if text == crlf { // End of headers
if len(getMatch) > 0 {
break Loop
}
if contentLength == 0 {
return bad("content-length header missing")
}
@@ -219,8 +217,8 @@ func (server *httpServer) handleHttpRequest(conn net.Conn) string {
apiKey = strings.TrimSpace(pair[1])
}
}
case 2:
body += text
case 2: // Request body
bodyBuilder.WriteString(text)
}
}
@@ -228,12 +226,21 @@ func (server *httpServer) handleHttpRequest(conn net.Conn) string {
return unauthorized("invalid api key")
}
if len(getMatch) > 0 {
response := server.getHandler(parseGetParams(getMatch[1]))
if len(response) > 0 {
return good(response)
}
return answer(httpUnavailable+jsonContentType, `{"error":"timeout"}`)
}
body := bodyBuilder.String()
if len(body) < contentLength {
return bad("incomplete request")
}
body = body[:contentLength]
actions, err := parseSingleActionList(strings.Trim(string(body), "\r\n"))
actions, err := parseSingleActionList(strings.Trim(string(body), "\r\n"), false)
if err != nil {
return bad(err.Error())
}
+1748 -649
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 -29
View File
@@ -1,39 +1,11 @@
package fzf
import (
"os"
"os/exec"
"github.com/junegunn/fzf/src/tui"
)
func runTmux(args []string, opts *Options) (int, error) {
// Prepare arguments
fzf, rest := args[0], args[1:]
args = []string{"--bind=ctrl-z:ignore"}
if !opts.Tmux.border && (opts.BorderShape == tui.BorderUndefined || opts.BorderShape == tui.BorderLine) {
// We append --border option at the end, because `--style=full:STYLE`
// may have changed the default border style.
if tui.DefaultBorderShape == tui.BorderRounded {
rest = append(rest, "--border=rounded")
} else {
rest = append(rest, "--border=sharp")
}
}
if opts.Tmux.border && opts.Margin == defaultMargin() {
args = append(args, "--margin=0,1")
}
argStr := escapeSingleQuote(fzf)
for _, arg := range append(args, rest...) {
argStr += " " + escapeSingleQuote(arg)
}
argStr += ` --no-tmux --no-height`
// Get current directory
dir, err := os.Getwd()
if err != nil {
dir = "."
}
argStr, dir := popupArgStr(args, opts)
// Set tmux options for popup placement
// C Both The centre of the terminal
+27 -7
View File
@@ -161,7 +161,7 @@ func awkTokenizer(input string) ([]string, int) {
end := 0
for idx := 0; idx < len(input); idx++ {
r := input[idx]
white := r == 9 || r == 32
white := r == 9 || r == 32 || r == 10
switch state {
case awkNil:
if white {
@@ -206,8 +206,9 @@ func Tokenize(text string, delimiter Delimiter) []Token {
if delimiter.regex != nil {
locs := delimiter.regex.FindAllStringIndex(text, -1)
begin := 0
for _, loc := range locs {
tokens = append(tokens, text[begin:loc[1]])
tokens = make([]string, len(locs))
for i, loc := range locs {
tokens[i] = text[begin:loc[1]]
begin = loc[1]
}
if begin < len(text) {
@@ -217,11 +218,12 @@ func Tokenize(text string, delimiter Delimiter) []Token {
return withPrefixLengths(tokens, 0)
}
// StripLastDelimiter removes the trailing delimiter and whitespaces
// StripLastDelimiter removes the trailing delimiter
func StripLastDelimiter(str string, delimiter Delimiter) string {
if delimiter.str != nil {
str = strings.TrimSuffix(str, *delimiter.str)
} else if delimiter.regex != nil {
return strings.TrimSuffix(str, *delimiter.str)
}
if delimiter.regex != nil {
locs := delimiter.regex.FindAllStringIndex(str, -1)
if len(locs) > 0 {
lastLoc := locs[len(locs)-1]
@@ -229,10 +231,28 @@ func StripLastDelimiter(str string, delimiter Delimiter) string {
str = str[:lastLoc[0]]
}
}
return str
}
return strings.TrimRightFunc(str, unicode.IsSpace)
}
func GetLastDelimiter(str string, delimiter Delimiter) string {
if delimiter.str != nil {
if strings.HasSuffix(str, *delimiter.str) {
return *delimiter.str
}
} else if delimiter.regex != nil {
locs := delimiter.regex.FindAllStringIndex(str, -1)
if len(locs) > 0 {
lastLoc := locs[len(locs)-1]
if lastLoc[1] == len(str) {
return str[lastLoc[0]:]
}
}
}
return ""
}
// JoinTokens concatenates the tokens into a single string
func JoinTokens(tokens []Token) string {
var output bytes.Buffer
@@ -284,7 +304,7 @@ func Transform(tokens []Token, withNth []Range) []Token {
end += numTokens + 1
}
}
minIdx = util.Max(0, begin-1)
minIdx = max(0, begin-1)
for idx := begin; idx <= end; idx++ {
if idx >= 1 && idx <= numTokens {
parts = append(parts, tokens[idx-1].text)
+5 -5
View File
@@ -56,9 +56,9 @@ func TestParseRange(t *testing.T) {
func TestTokenize(t *testing.T) {
// AWK-style
input := " abc: def: ghi "
input := " abc: \n\t def: ghi "
tokens := Tokenize(input, Delimiter{})
if tokens[0].text.ToString() != "abc: " || tokens[0].prefixLength != 2 {
if tokens[0].text.ToString() != "abc: \n\t " || tokens[0].prefixLength != 2 {
t.Errorf("%s", tokens)
}
@@ -71,9 +71,9 @@ func TestTokenize(t *testing.T) {
// With delimiter regex
tokens = Tokenize(input, delimiterRegexp("\\s+"))
if tokens[0].text.ToString() != " " || tokens[0].prefixLength != 0 ||
tokens[1].text.ToString() != "abc: " || tokens[1].prefixLength != 2 ||
tokens[2].text.ToString() != "def: " || tokens[2].prefixLength != 8 ||
tokens[3].text.ToString() != "ghi " || tokens[3].prefixLength != 14 {
tokens[1].text.ToString() != "abc: \n\t " || tokens[1].prefixLength != 2 ||
tokens[2].text.ToString() != "def: " || tokens[2].prefixLength != 10 ||
tokens[3].text.ToString() != "ghi " || tokens[3].prefixLength != 16 {
t.Errorf("%s", tokens)
}
}
+5 -5
View File
@@ -34,11 +34,11 @@ func (r *FullscreenRenderer) ShowCursor() {}
func (r *FullscreenRenderer) Refresh() {}
func (r *FullscreenRenderer) Close() {}
func (r *FullscreenRenderer) Size() TermSize { return TermSize{} }
func (r *FullscreenRenderer) GetChar() Event { return Event{} }
func (r *FullscreenRenderer) Top() int { return 0 }
func (r *FullscreenRenderer) MaxX() int { return 0 }
func (r *FullscreenRenderer) MaxY() int { return 0 }
func (r *FullscreenRenderer) Top() int { return 0 }
func (r *FullscreenRenderer) MaxX() int { return 0 }
func (r *FullscreenRenderer) MaxY() int { return 0 }
func (r *FullscreenRenderer) GetChar(bool) Event { return Event{} }
func (r *FullscreenRenderer) CancelGetChar() {}
func (r *FullscreenRenderer) RefreshWindows(windows []Window) {}
+68 -66
View File
@@ -16,7 +16,7 @@ func _() {
_ = x[CtrlE-5]
_ = x[CtrlF-6]
_ = x[CtrlG-7]
_ = x[CtrlBackspace-8]
_ = x[CtrlH-8]
_ = x[Tab-9]
_ = x[CtrlJ-10]
_ = x[CtrlK-11]
@@ -99,74 +99,76 @@ func _() {
_ = x[CtrlRight-88]
_ = x[CtrlHome-89]
_ = x[CtrlEnd-90]
_ = x[CtrlDelete-91]
_ = x[CtrlPageUp-92]
_ = x[CtrlPageDown-93]
_ = x[Alt-94]
_ = x[CtrlAlt-95]
_ = x[CtrlAltUp-96]
_ = x[CtrlAltDown-97]
_ = x[CtrlAltLeft-98]
_ = x[CtrlAltRight-99]
_ = x[CtrlAltHome-100]
_ = x[CtrlAltEnd-101]
_ = x[CtrlAltBackspace-102]
_ = x[CtrlAltDelete-103]
_ = x[CtrlAltPageUp-104]
_ = x[CtrlAltPageDown-105]
_ = x[CtrlShiftUp-106]
_ = x[CtrlShiftDown-107]
_ = x[CtrlShiftLeft-108]
_ = x[CtrlShiftRight-109]
_ = x[CtrlShiftHome-110]
_ = x[CtrlShiftEnd-111]
_ = x[CtrlShiftDelete-112]
_ = x[CtrlShiftPageUp-113]
_ = x[CtrlShiftPageDown-114]
_ = x[CtrlAltShiftUp-115]
_ = x[CtrlAltShiftDown-116]
_ = x[CtrlAltShiftLeft-117]
_ = x[CtrlAltShiftRight-118]
_ = x[CtrlAltShiftHome-119]
_ = x[CtrlAltShiftEnd-120]
_ = x[CtrlAltShiftDelete-121]
_ = x[CtrlAltShiftPageUp-122]
_ = x[CtrlAltShiftPageDown-123]
_ = x[Invalid-124]
_ = x[Fatal-125]
_ = x[BracketedPasteBegin-126]
_ = x[BracketedPasteEnd-127]
_ = x[Mouse-128]
_ = x[DoubleClick-129]
_ = x[LeftClick-130]
_ = x[RightClick-131]
_ = x[SLeftClick-132]
_ = x[SRightClick-133]
_ = x[ScrollUp-134]
_ = x[ScrollDown-135]
_ = x[SScrollUp-136]
_ = x[SScrollDown-137]
_ = x[PreviewScrollUp-138]
_ = x[PreviewScrollDown-139]
_ = x[Resize-140]
_ = x[Change-141]
_ = x[BackwardEOF-142]
_ = x[Start-143]
_ = x[Load-144]
_ = x[Focus-145]
_ = x[One-146]
_ = x[Zero-147]
_ = x[Result-148]
_ = x[Jump-149]
_ = x[JumpCancel-150]
_ = x[ClickHeader-151]
_ = x[ClickFooter-152]
_ = x[Multi-153]
_ = x[CtrlBackspace-91]
_ = x[CtrlDelete-92]
_ = x[CtrlPageUp-93]
_ = x[CtrlPageDown-94]
_ = x[Alt-95]
_ = x[CtrlAlt-96]
_ = x[CtrlAltUp-97]
_ = x[CtrlAltDown-98]
_ = x[CtrlAltLeft-99]
_ = x[CtrlAltRight-100]
_ = x[CtrlAltHome-101]
_ = x[CtrlAltEnd-102]
_ = x[CtrlAltBackspace-103]
_ = x[CtrlAltDelete-104]
_ = x[CtrlAltPageUp-105]
_ = x[CtrlAltPageDown-106]
_ = x[CtrlShiftUp-107]
_ = x[CtrlShiftDown-108]
_ = x[CtrlShiftLeft-109]
_ = x[CtrlShiftRight-110]
_ = x[CtrlShiftHome-111]
_ = x[CtrlShiftEnd-112]
_ = x[CtrlShiftDelete-113]
_ = x[CtrlShiftPageUp-114]
_ = x[CtrlShiftPageDown-115]
_ = x[CtrlAltShiftUp-116]
_ = x[CtrlAltShiftDown-117]
_ = x[CtrlAltShiftLeft-118]
_ = x[CtrlAltShiftRight-119]
_ = x[CtrlAltShiftHome-120]
_ = x[CtrlAltShiftEnd-121]
_ = x[CtrlAltShiftDelete-122]
_ = x[CtrlAltShiftPageUp-123]
_ = x[CtrlAltShiftPageDown-124]
_ = x[Mouse-125]
_ = x[DoubleClick-126]
_ = x[LeftClick-127]
_ = x[RightClick-128]
_ = x[SLeftClick-129]
_ = x[SRightClick-130]
_ = x[ScrollUp-131]
_ = x[ScrollDown-132]
_ = x[SScrollUp-133]
_ = x[SScrollDown-134]
_ = x[PreviewScrollUp-135]
_ = x[PreviewScrollDown-136]
_ = x[Invalid-137]
_ = x[Fatal-138]
_ = x[BracketedPasteBegin-139]
_ = x[BracketedPasteEnd-140]
_ = x[Resize-141]
_ = x[Change-142]
_ = x[BackwardEOF-143]
_ = x[Start-144]
_ = x[Load-145]
_ = x[Focus-146]
_ = x[One-147]
_ = x[Zero-148]
_ = x[Result-149]
_ = x[Jump-150]
_ = x[JumpCancel-151]
_ = x[ClickHeader-152]
_ = x[ClickFooter-153]
_ = x[Multi-154]
_ = x[Every-155]
}
const _EventType_name = "RuneCtrlACtrlBCtrlCCtrlDCtrlECtrlFCtrlGCtrlBackspaceTabCtrlJCtrlKCtrlLEnterCtrlNCtrlOCtrlPCtrlQCtrlRCtrlSCtrlTCtrlUCtrlVCtrlWCtrlXCtrlYCtrlZEscCtrlSpaceCtrlBackSlashCtrlRightBracketCtrlCaretCtrlSlashShiftTabBackspaceDeletePageUpPageDownUpDownLeftRightHomeEndInsertShiftUpShiftDownShiftLeftShiftRightShiftDeleteShiftHomeShiftEndShiftPageUpShiftPageDownF1F2F3F4F5F6F7F8F9F10F11F12AltBackspaceAltUpAltDownAltLeftAltRightAltDeleteAltHomeAltEndAltPageUpAltPageDownAltShiftUpAltShiftDownAltShiftLeftAltShiftRightAltShiftDeleteAltShiftHomeAltShiftEndAltShiftPageUpAltShiftPageDownCtrlUpCtrlDownCtrlLeftCtrlRightCtrlHomeCtrlEndCtrlDeleteCtrlPageUpCtrlPageDownAltCtrlAltCtrlAltUpCtrlAltDownCtrlAltLeftCtrlAltRightCtrlAltHomeCtrlAltEndCtrlAltBackspaceCtrlAltDeleteCtrlAltPageUpCtrlAltPageDownCtrlShiftUpCtrlShiftDownCtrlShiftLeftCtrlShiftRightCtrlShiftHomeCtrlShiftEndCtrlShiftDeleteCtrlShiftPageUpCtrlShiftPageDownCtrlAltShiftUpCtrlAltShiftDownCtrlAltShiftLeftCtrlAltShiftRightCtrlAltShiftHomeCtrlAltShiftEndCtrlAltShiftDeleteCtrlAltShiftPageUpCtrlAltShiftPageDownInvalidFatalBracketedPasteBeginBracketedPasteEndMouseDoubleClickLeftClickRightClickSLeftClickSRightClickScrollUpScrollDownSScrollUpSScrollDownPreviewScrollUpPreviewScrollDownResizeChangeBackwardEOFStartLoadFocusOneZeroResultJumpJumpCancelClickHeaderClickFooterMulti"
const _EventType_name = "RuneCtrlACtrlBCtrlCCtrlDCtrlECtrlFCtrlGCtrlHTabCtrlJCtrlKCtrlLEnterCtrlNCtrlOCtrlPCtrlQCtrlRCtrlSCtrlTCtrlUCtrlVCtrlWCtrlXCtrlYCtrlZEscCtrlSpaceCtrlBackSlashCtrlRightBracketCtrlCaretCtrlSlashShiftTabBackspaceDeletePageUpPageDownUpDownLeftRightHomeEndInsertShiftUpShiftDownShiftLeftShiftRightShiftDeleteShiftHomeShiftEndShiftPageUpShiftPageDownF1F2F3F4F5F6F7F8F9F10F11F12AltBackspaceAltUpAltDownAltLeftAltRightAltDeleteAltHomeAltEndAltPageUpAltPageDownAltShiftUpAltShiftDownAltShiftLeftAltShiftRightAltShiftDeleteAltShiftHomeAltShiftEndAltShiftPageUpAltShiftPageDownCtrlUpCtrlDownCtrlLeftCtrlRightCtrlHomeCtrlEndCtrlBackspaceCtrlDeleteCtrlPageUpCtrlPageDownAltCtrlAltCtrlAltUpCtrlAltDownCtrlAltLeftCtrlAltRightCtrlAltHomeCtrlAltEndCtrlAltBackspaceCtrlAltDeleteCtrlAltPageUpCtrlAltPageDownCtrlShiftUpCtrlShiftDownCtrlShiftLeftCtrlShiftRightCtrlShiftHomeCtrlShiftEndCtrlShiftDeleteCtrlShiftPageUpCtrlShiftPageDownCtrlAltShiftUpCtrlAltShiftDownCtrlAltShiftLeftCtrlAltShiftRightCtrlAltShiftHomeCtrlAltShiftEndCtrlAltShiftDeleteCtrlAltShiftPageUpCtrlAltShiftPageDownMouseDoubleClickLeftClickRightClickSLeftClickSRightClickScrollUpScrollDownSScrollUpSScrollDownPreviewScrollUpPreviewScrollDownInvalidFatalBracketedPasteBeginBracketedPasteEndResizeChangeBackwardEOFStartLoadFocusOneZeroResultJumpJumpCancelClickHeaderClickFooterMultiEvery"
var _EventType_index = [...]uint16{0, 4, 9, 14, 19, 24, 29, 34, 39, 52, 55, 60, 65, 70, 75, 80, 85, 90, 95, 100, 105, 110, 115, 120, 125, 130, 135, 140, 143, 152, 165, 181, 190, 199, 207, 216, 222, 228, 236, 238, 242, 246, 251, 255, 258, 264, 271, 280, 289, 299, 310, 319, 327, 338, 351, 353, 355, 357, 359, 361, 363, 365, 367, 369, 372, 375, 378, 390, 395, 402, 409, 417, 426, 433, 439, 448, 459, 469, 481, 493, 506, 520, 532, 543, 557, 573, 579, 587, 595, 604, 612, 619, 629, 639, 651, 654, 661, 670, 681, 692, 704, 715, 725, 741, 754, 767, 782, 793, 806, 819, 833, 846, 858, 873, 888, 905, 919, 935, 951, 968, 984, 999, 1017, 1035, 1055, 1062, 1067, 1086, 1103, 1108, 1119, 1128, 1138, 1148, 1159, 1167, 1177, 1186, 1197, 1212, 1229, 1235, 1241, 1252, 1257, 1261, 1266, 1269, 1273, 1279, 1283, 1293, 1304, 1315, 1320}
var _EventType_index = [...]uint16{0, 4, 9, 14, 19, 24, 29, 34, 39, 44, 47, 52, 57, 62, 67, 72, 77, 82, 87, 92, 97, 102, 107, 112, 117, 122, 127, 132, 135, 144, 157, 173, 182, 191, 199, 208, 214, 220, 228, 230, 234, 238, 243, 247, 250, 256, 263, 272, 281, 291, 302, 311, 319, 330, 343, 345, 347, 349, 351, 353, 355, 357, 359, 361, 364, 367, 370, 382, 387, 394, 401, 409, 418, 425, 431, 440, 451, 461, 473, 485, 498, 512, 524, 535, 549, 565, 571, 579, 587, 596, 604, 611, 624, 634, 644, 656, 659, 666, 675, 686, 697, 709, 720, 730, 746, 759, 772, 787, 798, 811, 824, 838, 851, 863, 878, 893, 910, 924, 940, 956, 973, 989, 1004, 1022, 1040, 1060, 1065, 1076, 1085, 1095, 1105, 1116, 1124, 1134, 1143, 1154, 1169, 1186, 1193, 1198, 1217, 1234, 1240, 1246, 1257, 1262, 1266, 1271, 1274, 1278, 1284, 1288, 1298, 1309, 1320, 1325, 1330}
func (i EventType) String() string {
if i < 0 || i >= EventType(len(_EventType_index)-1) {
+239 -182
View File
@@ -13,7 +13,6 @@ import (
"unicode/utf8"
"github.com/junegunn/fzf/src/util"
"github.com/rivo/uniseg"
"golang.org/x/term"
)
@@ -26,6 +25,7 @@ const (
escPollInterval = 5
offsetPollTries = 10
maxInputBuffer = 1024 * 1024
maxSelectTries = 100
)
const DefaultTtyDevice string = "/dev/tty"
@@ -49,13 +49,26 @@ const DIM string = "\x1b[2m"
const CR string = DIM + "␍"
const LF string = DIM + "␊"
type getCharResult int
const (
getCharSuccess getCharResult = iota
getCharError
getCharCancelled
)
func (r getCharResult) ok() bool {
return r == getCharSuccess
}
func (r *LightRenderer) stderrInternal(str string, allowNLCR bool, resetCode string) {
bytes := []byte(str)
runes := []rune{}
for len(bytes) > 0 {
r, sz := utf8.DecodeRune(bytes)
nlcr := r == '\n' || r == '\r'
if r >= 32 || r == '\x1b' || nlcr {
isC1 := r >= 0x80 && r <= 0x9F
if (r >= 32 && !isC1) || r == '\x1b' || nlcr {
if nlcr && !allowNLCR {
if r == '\r' {
runes = append(runes, []rune(CR+resetCode)...)
@@ -104,6 +117,7 @@ type LightRenderer struct {
clicks [][2]int
ttyin *os.File
ttyout *os.File
cancel func()
buffer []byte
origState *term.State
width int
@@ -118,9 +132,9 @@ type LightRenderer struct {
x int
maxHeightFunc func(int) int
showCursor bool
mutex sync.Mutex
// Windows only
mutex sync.Mutex
ttyinChannel chan byte
inHandle uintptr
outHandle uintptr
@@ -193,11 +207,6 @@ func (r *LightRenderer) Init() error {
if r.fullscreen {
r.smcup()
} else {
// We assume that --no-clear is used for repetitive relaunching of fzf.
// So we do not clear the lower bottom of the screen.
if r.clearOnExit {
r.csi("J")
}
y, x := r.findOffset()
r.mouse = r.mouse && y >= 0
// When --no-clear is used for repetitive relaunching, there is a small
@@ -208,6 +217,11 @@ func (r *LightRenderer) Init() error {
r.upOneLine = true
r.makeSpace()
}
// We assume that --no-clear is used for repetitive relaunching of fzf.
// So we do not clear the lower bottom of the screen.
if r.clearOnExit {
r.csi("J")
}
for i := 1; i < r.MaxY(); i++ {
r.makeSpace()
}
@@ -262,16 +276,18 @@ func getEnv(name string, defaultValue int) int {
return atoi(env, defaultValue)
}
func (r *LightRenderer) getBytes() ([]byte, error) {
bytes, err := r.getBytesInternal(r.buffer, false)
return bytes, err
func (r *LightRenderer) getBytes(cancellable bool) ([]byte, getCharResult, error) {
return r.getBytesInternal(cancellable, r.buffer, false)
}
func (r *LightRenderer) getBytesInternal(buffer []byte, nonblock bool) ([]byte, error) {
c, ok := r.getch(nonblock)
if !nonblock && !ok {
func (r *LightRenderer) getBytesInternal(cancellable bool, buffer []byte, nonblock bool) ([]byte, getCharResult, error) {
c, result := r.getch(cancellable, nonblock)
if result == getCharCancelled {
return buffer, getCharCancelled, nil
}
if !nonblock && !result.ok() {
r.Close()
return nil, errors.New("failed to read " + DefaultTtyDevice)
return nil, getCharError, errors.New("failed to read " + DefaultTtyDevice)
}
retries := 0
@@ -282,8 +298,8 @@ func (r *LightRenderer) getBytesInternal(buffer []byte, nonblock bool) ([]byte,
pc := c
for {
c, ok = r.getch(true)
if !ok {
c, result = r.getch(false, true)
if !result.ok() {
if retries > 0 {
retries--
time.Sleep(escPollInterval * time.Millisecond)
@@ -302,20 +318,24 @@ func (r *LightRenderer) getBytesInternal(buffer []byte, nonblock bool) ([]byte,
// so terminate fzf immediately.
if len(buffer) > maxInputBuffer {
r.Close()
return nil, fmt.Errorf("input buffer overflow (%d): %v", len(buffer), buffer)
return nil, getCharError, fmt.Errorf("input buffer overflow (%d): %v", len(buffer), buffer)
}
}
return buffer, nil
return buffer, getCharSuccess, nil
}
func (r *LightRenderer) GetChar() Event {
func (r *LightRenderer) GetChar(cancellable bool) Event {
var err error
var result getCharResult
if len(r.buffer) == 0 {
r.buffer, err = r.getBytes()
r.buffer, result, err = r.getBytes(cancellable)
if err != nil {
return Event{Fatal, 0, nil}
}
if result == getCharCancelled {
return Event{Invalid, 0, nil}
}
}
if len(r.buffer) == 0 {
return Event{Fatal, 0, nil}
@@ -351,9 +371,14 @@ func (r *LightRenderer) GetChar() Event {
ev := r.escSequence(&sz)
// Second chance
if ev.Type == Invalid {
if r.buffer, err = r.getBytes(); err != nil {
r.buffer, result, err = r.getBytes(true)
if err != nil {
return Event{Fatal, 0, nil}
}
if result == getCharCancelled {
return Event{Invalid, 0, nil}
}
ev = r.escSequence(&sz)
}
return ev
@@ -371,6 +396,21 @@ func (r *LightRenderer) GetChar() Event {
return Event{Rune, char, nil}
}
func (r *LightRenderer) CancelGetChar() {
r.mutex.Lock()
if r.cancel != nil {
r.cancel()
r.cancel = nil
}
r.mutex.Unlock()
}
func (r *LightRenderer) setCancel(f func()) {
r.mutex.Lock()
r.cancel = f
r.mutex.Unlock()
}
func (r *LightRenderer) escSequence(sz *int) Event {
if len(r.buffer) < 2 {
return Event{Esc, 0, nil}
@@ -650,6 +690,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
switch r.buffer[4] {
case '1', '2', '3', '4', '5', '6', '7', '8', '9':
// Kitty iTerm2 WezTerm
// ARROW "\e[1;1D"
// SHIFT-ARROW "\e[1;2D"
// ALT-SHIFT-ARROW "\e[1;4D" "\e[1;10D" "\e[1;4D"
// CTRL-SHIFT-ARROW "\e[1;6D" N/A
@@ -704,6 +745,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
if shift {
return Event{ShiftUp, 0, nil}
}
return Event{Up, 0, nil}
case 'B':
if ctrlAltShift {
return Event{CtrlAltShiftDown, 0, nil}
@@ -726,6 +768,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
if shift {
return Event{ShiftDown, 0, nil}
}
return Event{Down, 0, nil}
case 'C':
if ctrlAltShift {
return Event{CtrlAltShiftRight, 0, nil}
@@ -748,6 +791,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
if alt {
return Event{AltRight, 0, nil}
}
return Event{Right, 0, nil}
case 'D':
if ctrlAltShift {
return Event{CtrlAltShiftLeft, 0, nil}
@@ -770,6 +814,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
if shift {
return Event{ShiftLeft, 0, nil}
}
return Event{Left, 0, nil}
case 'H':
if ctrlAltShift {
return Event{CtrlAltShiftHome, 0, nil}
@@ -792,6 +837,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
if shift {
return Event{ShiftHome, 0, nil}
}
return Event{Home, 0, nil}
case 'F':
if ctrlAltShift {
return Event{CtrlAltShiftEnd, 0, nil}
@@ -814,6 +860,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
if shift {
return Event{ShiftEnd, 0, nil}
}
return Event{End, 0, nil}
}
} // r.buffer[4]
} // r.buffer[3]
@@ -1033,8 +1080,8 @@ func (r *LightRenderer) MaxY() int {
}
func (r *LightRenderer) NewWindow(top int, left int, width int, height int, windowType WindowType, borderStyle BorderStyle, erase bool) Window {
width = util.Max(0, width)
height = util.Max(0, height)
width = max(0, width)
height = max(0, height)
w := &LightWindow{
renderer: r,
colored: r.theme.Colored,
@@ -1083,127 +1130,144 @@ func (w *LightWindow) DrawHBorder() {
w.drawBorder(true)
}
// drawHLine fills row `row` with `line` between optional left/right caps.
// A zero rune means "no cap"; caps are placed at the very edges of `w`.
func (w *LightWindow) drawHLine(row int, line, leftCap, rightCap rune, color ColorPair) {
w.Move(row, 0)
hw := runeWidth(line)
width := w.width
if leftCap != 0 {
w.CPrint(color, string(leftCap))
width -= runeWidth(leftCap)
}
if rightCap != 0 {
width -= runeWidth(rightCap)
}
if width < 0 {
width = 0
}
inner := width / hw
rem := width - inner*hw
w.CPrint(color, repeat(line, inner)+repeat(' ', rem))
if rightCap != 0 {
w.CPrint(color, string(rightCap))
}
}
func (w *LightWindow) DrawHSeparator(row int, windowType WindowType, useBottom bool) {
if w.height == 0 {
return
}
shape := w.border.shape
if shape == BorderNone {
return
}
color := BorderColor(windowType)
line := w.border.top
if useBottom {
line = w.border.bottom
}
var leftCap, rightCap rune
if shape.HasLeft() {
leftCap = w.border.leftMid
}
if shape.HasRight() {
rightCap = w.border.rightMid
}
w.drawHLine(row, line, leftCap, rightCap, color)
}
func (w *LightWindow) PaintSectionFrame(topContent, bottomContent int, windowType WindowType, edge SectionEdge) {
if w.height == 0 || w.border.shape == BorderNone {
return
}
color := BorderColor(windowType)
shape := w.border.shape
hasLeft := shape.HasLeft()
hasRight := shape.HasRight()
rightW := runeWidth(w.border.right)
// Content rows: overpaint left/right verticals + their 1-char margin.
for row := topContent; row <= bottomContent; row++ {
if hasLeft {
w.Move(row, 0)
w.CPrint(color, string(w.border.left)+" ")
}
if hasRight {
w.Move(row, w.width-rightW-1)
w.CPrint(color, " "+string(w.border.right))
}
}
if edge == SectionEdgeTop && shape.HasTop() {
var leftCap, rightCap rune
if hasLeft {
leftCap = w.border.topLeft
}
if hasRight {
rightCap = w.border.topRight
}
w.drawHLine(0, w.border.top, leftCap, rightCap, color)
}
if edge == SectionEdgeBottom && shape.HasBottom() {
var leftCap, rightCap rune
if hasLeft {
leftCap = w.border.bottomLeft
}
if hasRight {
rightCap = w.border.bottomRight
}
w.drawHLine(w.height-1, w.border.bottom, leftCap, rightCap, color)
}
}
func (w *LightWindow) drawBorder(onlyHorizontal bool) {
if w.height == 0 {
return
}
switch w.border.shape {
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble:
w.drawBorderAround(onlyHorizontal)
case BorderHorizontal:
w.drawBorderHorizontal(true, true)
case BorderVertical:
if onlyHorizontal {
return
}
w.drawBorderVertical(true, true)
case BorderTop:
w.drawBorderHorizontal(true, false)
case BorderBottom:
w.drawBorderHorizontal(false, true)
case BorderLeft:
if onlyHorizontal {
return
}
w.drawBorderVertical(true, false)
case BorderRight:
if onlyHorizontal {
return
}
w.drawBorderVertical(false, true)
shape := w.border.shape
if shape == BorderNone {
return
}
}
color := BorderColor(w.windowType)
hasLeft := shape.HasLeft()
hasRight := shape.HasRight()
func (w *LightWindow) drawBorderHorizontal(top, bottom bool) {
color := ColBorder
switch w.windowType {
case WindowList:
color = ColListBorder
case WindowInput:
color = ColInputBorder
case WindowHeader:
color = ColHeaderBorder
case WindowFooter:
color = ColFooterBorder
case WindowPreview:
color = ColPreviewBorder
}
hw := runeWidth(w.border.top)
if top {
w.Move(0, 0)
w.CPrint(color, repeat(w.border.top, w.width/hw))
}
if bottom {
w.Move(w.height-1, 0)
w.CPrint(color, repeat(w.border.bottom, w.width/hw))
}
}
func (w *LightWindow) drawBorderVertical(left, right bool) {
vw := runeWidth(w.border.left)
color := ColBorder
switch w.windowType {
case WindowList:
color = ColListBorder
case WindowInput:
color = ColInputBorder
case WindowHeader:
color = ColHeaderBorder
case WindowFooter:
color = ColFooterBorder
case WindowPreview:
color = ColPreviewBorder
}
for y := 0; y < w.height; y++ {
if left {
w.Move(y, 0)
w.CPrint(color, string(w.border.left))
w.CPrint(color, " ") // Margin
if shape.HasTop() {
var leftCap, rightCap rune
if hasLeft {
leftCap = w.border.topLeft
}
if right {
w.Move(y, w.width-vw-1)
w.CPrint(color, " ") // Margin
w.CPrint(color, string(w.border.right))
if hasRight {
rightCap = w.border.topRight
}
w.drawHLine(0, w.border.top, leftCap, rightCap, color)
}
}
func (w *LightWindow) drawBorderAround(onlyHorizontal bool) {
w.Move(0, 0)
color := ColBorder
switch w.windowType {
case WindowList:
color = ColListBorder
case WindowInput:
color = ColInputBorder
case WindowHeader:
color = ColHeaderBorder
case WindowFooter:
color = ColFooterBorder
case WindowPreview:
color = ColPreviewBorder
}
hw := runeWidth(w.border.top)
tcw := runeWidth(w.border.topLeft) + runeWidth(w.border.topRight)
bcw := runeWidth(w.border.bottomLeft) + runeWidth(w.border.bottomRight)
rem := (w.width - tcw) % hw
w.CPrint(color, string(w.border.topLeft)+repeat(w.border.top, (w.width-tcw)/hw)+repeat(' ', rem)+string(w.border.topRight))
if !onlyHorizontal {
if !onlyHorizontal && (hasLeft || hasRight) {
vw := runeWidth(w.border.left)
for y := 1; y < w.height-1; y++ {
w.Move(y, 0)
w.CPrint(color, string(w.border.left))
w.CPrint(color, " ") // Margin
w.Move(y, w.width-vw-1)
w.CPrint(color, " ") // Margin
w.CPrint(color, string(w.border.right))
for y := 0; y < w.height; y++ {
// Corner rows are already painted by drawHLine above / below.
if (y == 0 && shape.HasTop()) || (y == w.height-1 && shape.HasBottom()) {
continue
}
if hasLeft {
w.Move(y, 0)
w.CPrint(color, string(w.border.left)+" ")
}
if hasRight {
w.Move(y, w.width-vw-1)
w.CPrint(color, " "+string(w.border.right))
}
}
}
w.Move(w.height-1, 0)
rem = (w.width - bcw) % hw
w.CPrint(color, string(w.border.bottomLeft)+repeat(w.border.bottom, (w.width-bcw)/hw)+repeat(' ', rem)+string(w.border.bottomRight))
if shape.HasBottom() {
var leftCap, rightCap rune
if hasLeft {
leftCap = w.border.bottomLeft
}
if hasRight {
rightCap = w.border.bottomRight
}
w.drawHLine(w.height-1, w.border.bottom, leftCap, rightCap, color)
}
}
func (w *LightWindow) csi(code string) string {
@@ -1283,7 +1347,18 @@ func attrCodes(attr Attr) []string {
codes = append(codes, "3")
}
if (attr & Underline) > 0 {
codes = append(codes, "4")
switch attr.UnderlineStyle() {
case UlStyleDouble:
codes = append(codes, "4:2")
case UlStyleCurly:
codes = append(codes, "4:3")
case UlStyleDotted:
codes = append(codes, "4:4")
case UlStyleDashed:
codes = append(codes, "4:5")
default:
codes = append(codes, "4")
}
}
if (attr & Blink) > 0 {
codes = append(codes, "5")
@@ -1321,8 +1396,27 @@ func colorCodes(fg Color, bg Color) []string {
return codes
}
func (w *LightWindow) csiColor(fg Color, bg Color, attr Attr) (bool, string) {
func ulColorCode(c Color) string {
if c == colDefault {
return ""
}
if c.is24() {
r := (c >> 16) & 0xff
g := (c >> 8) & 0xff
b := (c) & 0xff
return fmt.Sprintf("58;2;%d;%d;%d", r, g, b)
}
if c >= 0 && c < 256 {
return fmt.Sprintf("58;5;%d", c)
}
return ""
}
func (w *LightWindow) csiColor(fg Color, bg Color, ul Color, attr Attr) (bool, string) {
codes := append(attrCodes(attr), colorCodes(fg, bg)...)
if ulCode := ulColorCode(ul); ulCode != "" {
codes = append(codes, ulCode)
}
code := w.csi(";" + strings.Join(codes, ";") + "m")
return len(codes) > 0, code
}
@@ -1336,65 +1430,28 @@ func cleanse(str string) string {
}
func (w *LightWindow) CPrint(pair ColorPair, text string) {
_, code := w.csiColor(pair.Fg(), pair.Bg(), pair.Attr())
_, code := w.csiColor(pair.Fg(), pair.Bg(), pair.Ul(), pair.Attr())
w.stderrInternal(cleanse(text), false, code)
w.csi("0m")
}
func (w *LightWindow) cprint2(fg Color, bg Color, attr Attr, text string) {
hasColors, code := w.csiColor(fg, bg, attr)
hasColors, code := w.csiColor(fg, bg, colDefault, attr)
if hasColors {
defer w.csi("0m")
}
w.stderrInternal(cleanse(text), false, code)
}
type wrappedLine struct {
text string
displayWidth int
}
func wrapLine(input string, prefixLength int, initialMax int, tabstop int, wrapSignWidth int) []wrappedLine {
lines := []wrappedLine{}
width := 0
line := ""
gr := uniseg.NewGraphemes(input)
max := initialMax
for gr.Next() {
rs := gr.Runes()
str := string(rs)
var w int
if len(rs) == 1 && rs[0] == '\t' {
w = tabstop - (prefixLength+width)%tabstop
str = repeat(' ', w)
} else if rs[0] == '\r' {
w++
} else {
w = uniseg.StringWidth(str)
}
width += w
if prefixLength+width <= max {
line += str
} else {
lines = append(lines, wrappedLine{string(line), width - w})
line = str
prefixLength = 0
width = w
max = initialMax - wrapSignWidth
}
}
lines = append(lines, wrappedLine{string(line), width})
return lines
}
func (w *LightWindow) fill(str string, resetCode string) FillReturn {
allLines := strings.Split(str, "\n")
for i, line := range allLines {
lines := wrapLine(line, w.posx, w.width, w.tabstop, w.wrapSignWidth)
lines := WrapLine(line, w.posx, w.width, w.tabstop, w.wrapSignWidth)
for j, wl := range lines {
w.stderrInternal(wl.text, false, resetCode)
w.posx += wl.displayWidth
if w.posx < w.width {
w.stderrInternal(wl.Text, false, resetCode)
w.posx += wl.DisplayWidth
}
// Wrap line
if j < len(lines)-1 || i < len(allLines)-1 {
@@ -1432,7 +1489,7 @@ func (w *LightWindow) fill(str string, resetCode string) FillReturn {
func (w *LightWindow) setBg() string {
if w.bg != colDefault {
_, code := w.csiColor(colDefault, w.bg, AttrRegular)
_, code := w.csiColor(colDefault, w.bg, colDefault, AttrRegular)
return code
}
// Should clear dim attribute after ␍ in the preview window
@@ -1454,7 +1511,7 @@ func (w *LightWindow) Fill(text string) FillReturn {
return w.fill(text, code)
}
func (w *LightWindow) CFill(fg Color, bg Color, attr Attr, text string) FillReturn {
func (w *LightWindow) CFill(fg Color, bg Color, ul Color, attr Attr, text string) FillReturn {
w.Move(w.posy, w.posx)
if fg == colDefault {
fg = w.fg
@@ -1462,7 +1519,7 @@ func (w *LightWindow) CFill(fg Color, bg Color, attr Attr, text string) FillRetu
if bg == colDefault {
bg = w.bg
}
if hasColors, resetCode := w.csiColor(fg, bg, attr); hasColors {
if hasColors, resetCode := w.csiColor(fg, bg, ul, attr); hasColors {
defer w.csi("0m")
return w.fill(text, resetCode)
}
+18 -1
View File
@@ -15,10 +15,27 @@ func TestLightRenderer(t *testing.T) {
light_renderer := renderer.(*LightRenderer)
go func() {
for {
light_renderer.mutex.Lock()
ready := light_renderer.cancel != nil
light_renderer.mutex.Unlock()
if ready {
light_renderer.CancelGetChar()
break
}
}
}()
event := light_renderer.GetChar(true)
if event.Type != Invalid {
t.Error("Not cancelled")
}
assertCharSequence := func(sequence string, name string) {
bytes := []byte(sequence)
light_renderer.buffer = bytes
event := light_renderer.GetChar()
event := light_renderer.GetChar(true)
if event.KeyName() != name {
t.Errorf(
"sequence: %q | %v | '%s' (%s) != %s",
+56 -9
View File
@@ -98,8 +98,8 @@ func (r *LightRenderer) findOffset() (row int, col int) {
r.flush()
var err error
bytes := []byte{}
for tries := 0; tries < offsetPollTries; tries++ {
bytes, err = r.getBytesInternal(bytes, tries > 0)
for tries := range offsetPollTries {
bytes, _, err = r.getBytesInternal(false, bytes, tries > 0)
if err != nil {
return -1, -1
}
@@ -114,15 +114,62 @@ func (r *LightRenderer) findOffset() (row int, col int) {
return -1, -1
}
func (r *LightRenderer) getch(nonblock bool) (int, bool) {
b := make([]byte, 1)
func (r *LightRenderer) getch(cancellable bool, nonblock bool) (int, getCharResult) {
fd := r.fd()
util.SetNonblock(r.ttyin, nonblock)
_, err := util.Read(fd, b)
if err != nil {
return 0, false
getter := func() (int, getCharResult) {
b := make([]byte, 1)
util.SetNonblock(r.ttyin, nonblock)
_, err := util.Read(fd, b)
if err != nil {
return 0, getCharError
}
return int(b[0]), getCharSuccess
}
return int(b[0]), true
if nonblock || !cancellable {
return getter()
}
rpipe, wpipe, err := os.Pipe()
if err != nil {
// Fallback to blocking read without cancellation
return getter()
}
r.setCancel(func() {
wpipe.Write([]byte{0})
})
defer func() {
r.setCancel(nil)
rpipe.Close()
wpipe.Close()
}()
cancelFd := int(rpipe.Fd())
for range maxSelectTries {
var rfds unix.FdSet
limit := len(rfds.Bits) * unix.NFDBITS
if fd >= limit || cancelFd >= limit {
return getter()
}
rfds.Set(fd)
rfds.Set(cancelFd)
_, err := unix.Select(max(fd, cancelFd)+1, &rfds, nil, nil, nil)
if err != nil {
if err == syscall.EINTR {
continue
}
return 0, getCharError
}
if rfds.IsSet(cancelFd) {
return 0, getCharCancelled
}
if rfds.IsSet(fd) {
return getter()
}
}
return 0, getCharError
}
func (r *LightRenderer) Size() TermSize {
+27 -10
View File
@@ -151,16 +151,33 @@ func (r *LightRenderer) findOffset() (row int, col int) {
return int(bufferInfo.CursorPosition.Y), int(bufferInfo.CursorPosition.X)
}
func (r *LightRenderer) getch(nonblock bool) (int, bool) {
if nonblock {
select {
case bc := <-r.ttyinChannel:
return int(bc), true
case <-time.After(timeoutInterval * time.Millisecond):
return 0, false
}
} else {
func (r *LightRenderer) getch(cancellable bool, nonblock bool) (int, getCharResult) {
if !nonblock && !cancellable {
bc := <-r.ttyinChannel
return int(bc), true
return int(bc), getCharSuccess
}
var timeout <-chan time.Time
if nonblock {
timeout = time.After(timeoutInterval * time.Millisecond)
}
var cancel chan struct{}
if cancellable {
cancel = make(chan struct{})
r.setCancel(func() {
close(cancel)
})
defer r.setCancel(nil)
}
select {
case bc := <-r.ttyinChannel:
return int(bc), getCharSuccess
case <-cancel:
return 0, getCharCancelled
case <-timeout:
// NOTE: not really an error
return 0, getCharError
}
}
+231 -112
View File
@@ -5,6 +5,7 @@ package tui
import (
"os"
"regexp"
"strings"
"time"
"github.com/gdamore/tcell/v2"
@@ -53,6 +54,7 @@ type TcellWindow struct {
showCursor bool
wrapSign string
wrapSignWidth int
tabstop int
}
func (w *TcellWindow) Top() int {
@@ -246,7 +248,7 @@ func (r *FullscreenRenderer) Size() TermSize {
return TermSize{lines, cols, 0, 0}
}
func (r *FullscreenRenderer) GetChar() Event {
func (r *FullscreenRenderer) GetChar(cancellable bool) Event {
ev := _screen.PollEvent()
switch ev := ev.(type) {
case *tcell.EventPaste:
@@ -371,10 +373,12 @@ func (r *FullscreenRenderer) GetChar() Event {
}
case rune(tcell.KeyCtrlH):
switch {
case ctrl:
return keyfn('h')
case alt:
return Event{AltBackspace, 0, nil}
case ctrl, none, shift:
return keyfn('h')
case none, shift:
return Event{Backspace, 0, nil}
}
}
case tcell.KeyCtrlI:
@@ -701,6 +705,10 @@ func (r *FullscreenRenderer) GetChar() Event {
return Event{Invalid, 0, nil}
}
func (r *FullscreenRenderer) CancelGetChar() {
// TODO
}
func (r *FullscreenRenderer) Pause(clear bool) {
if clear {
_screen.Suspend()
@@ -727,8 +735,8 @@ func (r *FullscreenRenderer) RefreshWindows(windows []Window) {
}
func (r *FullscreenRenderer) NewWindow(top int, left int, width int, height int, windowType WindowType, borderStyle BorderStyle, erase bool) Window {
width = util.Max(0, width)
height = util.Max(0, height)
width = max(0, width)
height = max(0, height)
normal := ColBorder
switch windowType {
case WindowList:
@@ -751,7 +759,8 @@ func (r *FullscreenRenderer) NewWindow(top int, left int, width int, height int,
height: height,
normal: normal,
borderStyle: borderStyle,
showCursor: r.showCursor}
showCursor: r.showCursor,
tabstop: r.tabstop}
w.Erase()
return w
}
@@ -819,6 +828,21 @@ func (w *TcellWindow) withUrl(style tcell.Style) tcell.Style {
return style
}
func underlineStyleFromAttr(a Attr) tcell.UnderlineStyle {
switch a.UnderlineStyle() {
case UlStyleDouble:
return tcell.UnderlineStyleDouble
case UlStyleCurly:
return tcell.UnderlineStyleCurly
case UlStyleDotted:
return tcell.UnderlineStyleDotted
case UlStyleDashed:
return tcell.UnderlineStyleDashed
default:
return tcell.UnderlineStyleSolid
}
}
func (w *TcellWindow) printString(text string, pair ColorPair) {
lx := 0
a := pair.Attr()
@@ -827,11 +851,18 @@ func (w *TcellWindow) printString(text string, pair ColorPair) {
if a&AttrClear == 0 {
style = style.
Reverse(a&Attr(tcell.AttrReverse) != 0).
Underline(a&Attr(tcell.AttrUnderline) != 0).
StrikeThrough(a&Attr(tcell.AttrStrikeThrough) != 0).
Italic(a&Attr(tcell.AttrItalic) != 0).
Blink(a&Attr(tcell.AttrBlink) != 0).
Dim(a&Attr(tcell.AttrDim) != 0)
if a&Attr(tcell.AttrUnderline) != 0 {
style = style.Underline(underlineStyleFromAttr(a))
if pair.Ul() != colDefault {
style = style.Underline(asTcellColor(pair.Ul()))
}
} else {
style = style.Underline(false)
}
}
style = w.withUrl(style)
@@ -866,10 +897,8 @@ func (w *TcellWindow) CPrint(pair ColorPair, text string) {
w.printString(text, pair)
}
func (w *TcellWindow) fillString(text string, pair ColorPair) FillReturn {
lx := 0
func (w *TcellWindow) pairStyle(pair ColorPair) tcell.Style {
a := pair.Attr()
var style tcell.Style
if w.color {
style = pair.style()
@@ -881,64 +910,73 @@ func (w *TcellWindow) fillString(text string, pair ColorPair) FillReturn {
Bold(a&Attr(tcell.AttrBold) != 0 || a&BoldForce != 0).
Dim(a&Attr(tcell.AttrDim) != 0).
Reverse(a&Attr(tcell.AttrReverse) != 0).
Underline(a&Attr(tcell.AttrUnderline) != 0).
StrikeThrough(a&Attr(tcell.AttrStrikeThrough) != 0).
Italic(a&Attr(tcell.AttrItalic) != 0)
style = w.withUrl(style)
if a&Attr(tcell.AttrUnderline) != 0 {
style = style.Underline(underlineStyleFromAttr(a))
if pair.Ul() != colDefault {
style = style.Underline(asTcellColor(pair.Ul()))
}
} else {
style = style.Underline(false)
}
return w.withUrl(style)
}
func (w *TcellWindow) renderGraphemes(text string, style tcell.Style) {
gr := uniseg.NewGraphemes(text)
Loop:
for gr.Next() {
st := style
rs := gr.Runes()
if len(rs) == 1 {
r := rs[0]
switch r {
case '\r':
st = style.Dim(true)
rs[0] = '␍'
case '\n':
w.lastY++
w.lastX = 0
lx = 0
continue Loop
}
}
// word wrap:
xPos := w.left + w.lastX + lx
if xPos >= w.left+w.width {
w.lastY++
if w.lastY >= w.height {
return FillSuspend
}
w.lastX = 0
lx = 0
xPos = w.left
sign := w.wrapSign
if w.wrapSignWidth > w.width {
runes, _ := util.Truncate(sign, w.width)
sign = string(runes)
}
wgr := uniseg.NewGraphemes(sign)
for wgr.Next() {
rs := wgr.Runes()
_screen.SetContent(w.left+lx, w.top+w.lastY, rs[0], rs[1:], style.Dim(true))
lx += uniseg.StringWidth(string(rs))
}
xPos = w.left + lx
if len(rs) == 1 && rs[0] == '\r' {
st = style.Dim(true)
rs[0] = '␍'
}
xPos := w.left + w.lastX
yPos := w.top + w.lastY
if yPos >= (w.top + w.height) {
return FillSuspend
if xPos < (w.left+w.width) && yPos < (w.top+w.height) {
_screen.SetContent(xPos, yPos, rs[0], rs[1:], st)
}
_screen.SetContent(xPos, yPos, rs[0], rs[1:], st)
lx += util.StringWidth(string(rs))
w.lastX += util.StringWidth(string(rs))
}
w.lastX += lx
if w.lastX == w.width {
}
func (w *TcellWindow) renderWrapSign(style tcell.Style) {
sign := w.wrapSign
if w.wrapSignWidth > w.width {
runes, _ := util.Truncate(sign, w.width)
sign = string(runes)
}
gr := uniseg.NewGraphemes(sign)
for gr.Next() {
rs := gr.Runes()
_screen.SetContent(w.left+w.lastX, w.top+w.lastY, rs[0], rs[1:], style.Dim(true))
w.lastX += uniseg.StringWidth(string(rs))
}
}
func (w *TcellWindow) fillString(text string, pair ColorPair) FillReturn {
style := w.pairStyle(pair)
for i, segment := range strings.Split(text, "\n") {
for j, wl := range WrapLine(segment, w.lastX, w.width, w.tabstop, w.wrapSignWidth) {
if i > 0 || j > 0 {
w.lastY++
if w.lastY >= w.height {
return FillSuspend
}
w.lastX = 0
if j > 0 {
w.renderWrapSign(style)
}
}
if w.lastX < w.width {
w.renderGraphemes(wl.Text, style)
}
}
}
if w.lastX >= w.width {
w.lastY++
w.lastX = 0
return FillNextLine
@@ -961,14 +999,14 @@ func (w *TcellWindow) LinkEnd() {
w.params = nil
}
func (w *TcellWindow) CFill(fg Color, bg Color, a Attr, str string) FillReturn {
func (w *TcellWindow) CFill(fg Color, bg Color, ul Color, a Attr, str string) FillReturn {
if fg == colDefault {
fg = w.normal.Fg()
}
if bg == colDefault {
bg = w.normal.Bg()
}
return w.fillString(str, NewColorPair(fg, bg, a))
return w.fillString(str, NewColorPair(fg, bg, a).WithUl(ul))
}
func (w *TcellWindow) DrawBorder() {
@@ -979,6 +1017,115 @@ func (w *TcellWindow) DrawHBorder() {
w.drawBorder(true)
}
// borderStyleFor returns the tcell.Style used to draw borders for `wt`, honoring
// whether the window is rendering with colors.
func (w *TcellWindow) borderStyleFor(wt WindowType) tcell.Style {
if !w.color {
return w.normal.style()
}
return BorderColor(wt).style()
}
// drawHLine fills row `y` with `line` between optional left/right caps.
// A zero rune means "no cap"; caps are placed at the very edges of `w`.
// tcell has an issue displaying two overlapping wide runes, so the line
// stops before the cap position rather than overpainting.
func (w *TcellWindow) drawHLine(y int, line, leftCap, rightCap rune, style tcell.Style) {
left := w.left
right := left + w.width
hw := runeWidth(line)
lw := 0
rw := 0
if leftCap != 0 {
lw = runeWidth(leftCap)
}
if rightCap != 0 {
rw = runeWidth(rightCap)
}
for x := left + lw; x <= right-rw-hw; x += hw {
_screen.SetContent(x, y, line, nil, style)
}
if leftCap != 0 {
_screen.SetContent(left, y, leftCap, nil, style)
}
if rightCap != 0 {
_screen.SetContent(right-rw, y, rightCap, nil, style)
}
}
func (w *TcellWindow) DrawHSeparator(row int, windowType WindowType, useBottom bool) {
if w.height == 0 {
return
}
shape := w.borderStyle.shape
if shape == BorderNone {
return
}
style := w.borderStyleFor(windowType)
line := w.borderStyle.top
if useBottom {
line = w.borderStyle.bottom
}
var leftCap, rightCap rune
if shape.HasLeft() {
leftCap = w.borderStyle.leftMid
}
if shape.HasRight() {
rightCap = w.borderStyle.rightMid
}
w.drawHLine(w.top+row, line, leftCap, rightCap, style)
}
func (w *TcellWindow) PaintSectionFrame(topContent, bottomContent int, windowType WindowType, edge SectionEdge) {
if w.height == 0 {
return
}
shape := w.borderStyle.shape
if shape == BorderNone {
return
}
style := w.borderStyleFor(windowType)
left := w.left
right := left + w.width
hasLeft := shape.HasLeft()
hasRight := shape.HasRight()
leftW := runeWidth(w.borderStyle.left)
rightW := runeWidth(w.borderStyle.right)
// Content rows: overpaint the left and right verticals (+ their 1-char margin) in
// the section's color. Inner margin stays at whatever bg the sub-window set.
for row := topContent; row <= bottomContent; row++ {
y := w.top + row
if hasLeft {
_screen.SetContent(left, y, w.borderStyle.left, nil, style)
_screen.SetContent(left+leftW, y, ' ', nil, style)
}
if hasRight {
_screen.SetContent(right-rightW-1, y, ' ', nil, style)
_screen.SetContent(right-rightW, y, w.borderStyle.right, nil, style)
}
}
if edge == SectionEdgeTop && shape.HasTop() {
var leftCap, rightCap rune
if hasLeft {
leftCap = w.borderStyle.topLeft
}
if hasRight {
rightCap = w.borderStyle.topRight
}
w.drawHLine(w.top, w.borderStyle.top, leftCap, rightCap, style)
}
if edge == SectionEdgeBottom && shape.HasBottom() {
var leftCap, rightCap rune
if hasLeft {
leftCap = w.borderStyle.bottomLeft
}
if hasRight {
rightCap = w.borderStyle.bottomRight
}
w.drawHLine(w.top+w.height-1, w.borderStyle.bottom, leftCap, rightCap, style)
}
}
func (w *TcellWindow) drawBorder(onlyHorizontal bool) {
if w.height == 0 {
return
@@ -993,72 +1140,44 @@ func (w *TcellWindow) drawBorder(onlyHorizontal bool) {
top := w.top
bot := top + w.height
var style tcell.Style
if w.color {
switch w.windowType {
case WindowBase:
style = ColBorder.style()
case WindowList:
style = ColListBorder.style()
case WindowHeader:
style = ColHeaderBorder.style()
case WindowFooter:
style = ColFooterBorder.style()
case WindowInput:
style = ColInputBorder.style()
case WindowPreview:
style = ColPreviewBorder.style()
}
} else {
style = w.normal.style()
}
style := w.borderStyleFor(w.windowType)
hw := runeWidth(w.borderStyle.top)
switch shape {
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble, BorderHorizontal, BorderTop:
max := right - 2*hw
if shape == BorderHorizontal || shape == BorderTop {
max = right - hw
hasLeft := shape.HasLeft()
hasRight := shape.HasRight()
if shape.HasTop() {
var leftCap, rightCap rune
if hasLeft {
leftCap = w.borderStyle.topLeft
}
// tcell has an issue displaying two overlapping wide runes
// e.g. SetContent( HH )
// SetContent( TR )
// ==================
// ( HH ) => TR is ignored
for x := left; x <= max; x += hw {
_screen.SetContent(x, top, w.borderStyle.top, nil, style)
if hasRight {
rightCap = w.borderStyle.topRight
}
w.drawHLine(top, w.borderStyle.top, leftCap, rightCap, style)
}
switch shape {
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble, BorderHorizontal, BorderBottom:
max := right - 2*hw
if shape == BorderHorizontal || shape == BorderBottom {
max = right - hw
if shape.HasBottom() {
var leftCap, rightCap rune
if hasLeft {
leftCap = w.borderStyle.bottomLeft
}
for x := left; x <= max; x += hw {
_screen.SetContent(x, bot-1, w.borderStyle.bottom, nil, style)
if hasRight {
rightCap = w.borderStyle.bottomRight
}
w.drawHLine(bot-1, w.borderStyle.bottom, leftCap, rightCap, style)
}
if !onlyHorizontal {
switch shape {
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble, BorderVertical, BorderLeft:
for y := top; y < bot; y++ {
vw := runeWidth(w.borderStyle.right)
for y := top; y < bot; y++ {
// Corner rows are already painted by drawHLine above / below.
if (y == top && shape.HasTop()) || (y == bot-1 && shape.HasBottom()) {
continue
}
if hasLeft {
_screen.SetContent(left, y, w.borderStyle.left, nil, style)
}
}
switch shape {
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble, BorderVertical, BorderRight:
vw := runeWidth(w.borderStyle.right)
for y := top; y < bot; y++ {
if hasRight {
_screen.SetContent(right-vw, y, w.borderStyle.right, nil, style)
}
}
}
switch shape {
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble:
_screen.SetContent(left, top, w.borderStyle.topLeft, nil, style)
_screen.SetContent(right-runeWidth(w.borderStyle.topRight), top, w.borderStyle.topRight, nil, style)
_screen.SetContent(left, bot-1, w.borderStyle.bottomLeft, nil, style)
_screen.SetContent(right-runeWidth(w.borderStyle.bottomRight), bot-1, w.borderStyle.bottomRight, nil, style)
}
}
+21 -21
View File
@@ -110,21 +110,21 @@ func TestGetCharEventKey(t *testing.T) {
{giveKey{tcell.KeyDelete, 0, tcell.ModAlt}, wantKey{AltDelete, 0, nil}},
{giveKey{tcell.KeyBackspace, 0, tcell.ModCtrl}, wantKey{CtrlBackspace, 0, nil}},
{giveKey{tcell.KeyBackspace, 0, tcell.ModCtrl | tcell.ModAlt}, wantKey{CtrlAltBackspace, 0, nil}},
{giveKey{tcell.KeyBackspace, 0, tcell.ModNone}, wantKey{Invalid, 0, nil}}, // fabricated, unhandled
{giveKey{tcell.KeyBS, 0, tcell.ModNone}, wantKey{Invalid, 0, nil}}, // fabricated, unhandled
{giveKey{tcell.KeyCtrlH, 0, tcell.ModNone}, wantKey{Invalid, 0, nil}}, // fabricated, unhandled
{giveKey{tcell.KeyCtrlH, rune(tcell.KeyCtrlH), tcell.ModNone}, wantKey{CtrlBackspace, 0, nil}}, // actual "Backspace" keystroke
{giveKey{tcell.KeyCtrlH, rune(tcell.KeyCtrlH), tcell.ModAlt}, wantKey{AltBackspace, 0, nil}}, // actual "Alt+Backspace" keystroke
{giveKey{tcell.KeyDEL, rune(tcell.KeyDEL), tcell.ModCtrl}, wantKey{CtrlBackspace, 0, nil}}, // actual "Ctrl+Backspace" keystroke
{giveKey{tcell.KeyCtrlH, rune(tcell.KeyCtrlH), tcell.ModShift}, wantKey{CtrlBackspace, 0, nil}}, // actual "Shift+Backspace" keystroke
{giveKey{tcell.KeyCtrlH, 0, tcell.ModCtrl | tcell.ModAlt}, wantKey{CtrlAltBackspace, 0, nil}}, // actual "Ctrl+Alt+Backspace" keystroke
{giveKey{tcell.KeyCtrlH, 0, tcell.ModCtrl | tcell.ModShift}, wantKey{CtrlBackspace, 0, nil}}, // actual "Ctrl+Shift+Backspace" keystroke
{giveKey{tcell.KeyCtrlH, rune(tcell.KeyCtrlH), tcell.ModShift | tcell.ModAlt}, wantKey{AltBackspace, 0, nil}}, // actual "Shift+Alt+Backspace" keystroke
{giveKey{tcell.KeyCtrlH, 0, tcell.ModCtrl | tcell.ModAlt | tcell.ModShift}, wantKey{CtrlAltBackspace, 0, nil}}, // actual "Ctrl+Shift+Alt+Backspace" keystroke
{giveKey{tcell.KeyCtrlH, rune(tcell.KeyCtrlH), tcell.ModCtrl}, wantKey{CtrlBackspace, 0, nil}}, // actual "Ctrl+H" keystroke
{giveKey{tcell.KeyCtrlH, rune(tcell.KeyCtrlH), tcell.ModCtrl | tcell.ModAlt}, wantKey{AltBackspace, 0, nil}}, // fabricated "Ctrl+Alt+H" keystroke
{giveKey{tcell.KeyCtrlH, rune(tcell.KeyCtrlH), tcell.ModCtrl | tcell.ModShift}, wantKey{CtrlBackspace, 0, nil}}, // actual "Ctrl+Shift+H" keystroke
{giveKey{tcell.KeyCtrlH, rune(tcell.KeyCtrlH), tcell.ModCtrl | tcell.ModAlt | tcell.ModShift}, wantKey{AltBackspace, 0, nil}}, // fabricated "Ctrl+Shift+Alt+H" keystroke
{giveKey{tcell.KeyBackspace, 0, tcell.ModNone}, wantKey{Invalid, 0, nil}}, // fabricated, unhandled
{giveKey{tcell.KeyBS, 0, tcell.ModNone}, wantKey{Invalid, 0, nil}}, // fabricated, unhandled
{giveKey{tcell.KeyCtrlH, 0, tcell.ModNone}, wantKey{Invalid, 0, nil}}, // fabricated, unhandled
{giveKey{tcell.KeyCtrlH, rune(tcell.KeyCtrlH), tcell.ModNone}, wantKey{Backspace, 0, nil}}, // actual "Backspace" keystroke
{giveKey{tcell.KeyCtrlH, rune(tcell.KeyCtrlH), tcell.ModAlt}, wantKey{AltBackspace, 0, nil}}, // actual "Alt+Backspace" keystroke
{giveKey{tcell.KeyDEL, rune(tcell.KeyDEL), tcell.ModCtrl}, wantKey{CtrlBackspace, 0, nil}}, // actual "Ctrl+Backspace" keystroke
{giveKey{tcell.KeyCtrlH, rune(tcell.KeyCtrlH), tcell.ModShift}, wantKey{Backspace, 0, nil}}, // actual "Shift+Backspace" keystroke
{giveKey{tcell.KeyCtrlH, 0, tcell.ModCtrl | tcell.ModAlt}, wantKey{CtrlAltBackspace, 0, nil}}, // actual "Ctrl+Alt+Backspace" keystroke
{giveKey{tcell.KeyCtrlH, 0, tcell.ModCtrl | tcell.ModShift}, wantKey{CtrlBackspace, 0, nil}}, // actual "Ctrl+Shift+Backspace" keystroke
{giveKey{tcell.KeyCtrlH, rune(tcell.KeyCtrlH), tcell.ModShift | tcell.ModAlt}, wantKey{AltBackspace, 0, nil}}, // actual "Shift+Alt+Backspace" keystroke
{giveKey{tcell.KeyCtrlH, 0, tcell.ModCtrl | tcell.ModAlt | tcell.ModShift}, wantKey{CtrlAltBackspace, 0, nil}}, // actual "Ctrl+Shift+Alt+Backspace" keystroke
{giveKey{tcell.KeyCtrlH, rune(tcell.KeyCtrlH), tcell.ModCtrl}, wantKey{CtrlH, 0, nil}}, // actual "Ctrl+H" keystroke
{giveKey{tcell.KeyCtrlH, rune(tcell.KeyCtrlH), tcell.ModCtrl | tcell.ModAlt}, wantKey{CtrlAlt, 'h', nil}}, // fabricated "Ctrl+Alt+H" keystroke
{giveKey{tcell.KeyCtrlH, rune(tcell.KeyCtrlH), tcell.ModCtrl | tcell.ModShift}, wantKey{CtrlH, 0, nil}}, // actual "Ctrl+Shift+H" keystroke
{giveKey{tcell.KeyCtrlH, rune(tcell.KeyCtrlH), tcell.ModCtrl | tcell.ModAlt | tcell.ModShift}, wantKey{CtrlAlt, 'h', nil}}, // fabricated "Ctrl+Shift+Alt+H" keystroke
// section 4: (Alt+Shift)+Key(Up|Down|Left|Right)
{giveKey{tcell.KeyUp, 0, tcell.ModNone}, wantKey{Up, 0, nil}},
@@ -253,7 +253,7 @@ func TestGetCharEventKey(t *testing.T) {
{giveKey{tcell.KeyPause, 0, tcell.ModNone}, wantKey{Invalid, 0, nil}}, // unhandled
}
r := NewFullscreenRenderer(&ColorTheme{}, false, false)
r := NewFullscreenRenderer(&ColorTheme{}, false, false, 8)
r.Init()
// run and evaluate the tests
@@ -265,22 +265,22 @@ func TestGetCharEventKey(t *testing.T) {
t.Logf("giveEvent = %T{key: %v, ch: %q (%[3]v), mod: %#04b}\n", giveEvent, giveEvent.Key(), giveEvent.Rune(), giveEvent.Modifiers())
// process the event in fzf and evaluate the test
gotEvent := r.GetChar()
gotEvent := r.GetChar(true)
// skip Resize events, those are sometimes put in the buffer outside of this test
if initialResizeAsInvalid && gotEvent.Type == Invalid {
t.Logf("Resize as Invalid swallowed")
initialResizeAsInvalid = false
gotEvent = r.GetChar()
gotEvent = r.GetChar(true)
}
if gotEvent.Type == Resize {
t.Logf("Resize swallowed")
gotEvent = r.GetChar()
gotEvent = r.GetChar(true)
}
t.Logf("wantEvent = %T{Type: %v, Char: %q (%[3]v)}\n", test.wantKey, test.wantKey.Type, test.wantKey.Char)
t.Logf("gotEvent = %T{Type: %v, Char: %q (%[3]v)}\n", gotEvent, gotEvent.Type, gotEvent.Char)
assert(t, "r.GetChar().Type", gotEvent.Type, test.wantKey.Type)
assert(t, "r.GetChar().Char", gotEvent.Char, test.wantKey.Char)
assert(t, "r.GetChar(true).Type", gotEvent.Type, test.wantKey.Type)
assert(t, "r.GetChar(true).Char", gotEvent.Char, test.wantKey.Char)
}
r.Close()
+295 -85
View File
@@ -2,7 +2,9 @@ package tui
import (
"strconv"
"strings"
"time"
"unicode"
"github.com/junegunn/fzf/src/util"
"github.com/rivo/uniseg"
@@ -17,15 +19,34 @@ const (
BoldForce = Attr(1 << 10)
FullBg = Attr(1 << 11)
Strip = Attr(1 << 12)
// Underline style stored in bits 13-15 (3 bits, values 0-4)
// Only meaningful when the Underline attribute bit is also set.
// 0 = solid (default)
UnderlineStyleShift = 13
UnderlineStyleMask = Attr(0b111 << UnderlineStyleShift)
UlStyleDouble = Attr(0b001 << UnderlineStyleShift)
UlStyleCurly = Attr(0b010 << UnderlineStyleShift)
UlStyleDotted = Attr(0b011 << UnderlineStyleShift)
UlStyleDashed = Attr(0b100 << UnderlineStyleShift)
)
func (a Attr) UnderlineStyle() Attr {
return a & UnderlineStyleMask
}
func (a Attr) Merge(b Attr) Attr {
if b&AttrRegular > 0 {
// Only keep bold attribute set by the system
return (b &^ AttrRegular) | (a & BoldForce)
}
return (a &^ AttrRegular) | b
merged := (a &^ AttrRegular) | b
// When b sets Underline, use b's underline style instead of OR'ing
if b&Underline > 0 {
merged = (merged &^ UnderlineStyleMask) | (b & UnderlineStyleMask)
}
return merged
}
// Types of user action
@@ -43,7 +64,7 @@ const (
CtrlE
CtrlF
CtrlG
CtrlBackspace
CtrlH
Tab
CtrlJ
CtrlK
@@ -137,6 +158,7 @@ const (
CtrlRight
CtrlHome
CtrlEnd
CtrlBackspace
CtrlDelete
CtrlPageUp
CtrlPageDown
@@ -175,11 +197,6 @@ const (
CtrlAltShiftPageUp
CtrlAltShiftPageDown
Invalid
Fatal
BracketedPasteBegin
BracketedPasteEnd
Mouse
DoubleClick
LeftClick
@@ -193,7 +210,15 @@ const (
PreviewScrollUp
PreviewScrollDown
// Events
// Synthetic / non-user events. Everything from Invalid onward is
// either internally generated or a state-change notification, not
// direct user input. Use `>= Invalid` to gate activity tracking.
// BracketedPasteBegin/End sit here too: they enclose user input
// (which arrives as Rune events) and should not appear in FZF_KEY.
Invalid
Fatal
BracketedPasteBegin
BracketedPasteEnd
Resize
Change
BackwardEOF
@@ -208,6 +233,7 @@ const (
ClickHeader
ClickFooter
Multi
Every
)
func (t EventType) AsEvent() Event {
@@ -227,6 +253,12 @@ func (e Event) Comparable() Event {
return Event{e.Type, e.Char, nil}
}
// Printable returns true if the event is a printable character that can be
// inserted into the query (e.g. via the 'put' action).
func (e Event) Printable() bool {
return e.Type == Rune && unicode.IsGraphic(e.Char)
}
func (e Event) KeyName() string {
if me := e.MouseEvent; me != nil {
return me.Name()
@@ -351,6 +383,7 @@ const (
type ColorPair struct {
fg Color
bg Color
ul Color
attr Attr
}
@@ -362,11 +395,11 @@ func HexToColor(rrggbb string) Color {
}
func NewColorPair(fg Color, bg Color, attr Attr) ColorPair {
return ColorPair{fg, bg, attr}
return ColorPair{fg, bg, colDefault, attr}
}
func NoColorPair() ColorPair {
return ColorPair{-1, -1, 0}
return ColorPair{-1, -1, -1, 0}
}
func (p ColorPair) Fg() Color {
@@ -377,6 +410,16 @@ func (p ColorPair) Bg() Color {
return p.bg
}
func (p ColorPair) Ul() Color {
return p.ul
}
func (p ColorPair) WithUl(ul Color) ColorPair {
dup := p
dup.ul = ul
return dup
}
func (p ColorPair) Attr() Attr {
return p.attr
}
@@ -403,6 +446,9 @@ func (p ColorPair) merge(other ColorPair, except Color) ColorPair {
if other.bg != except {
dup.bg = other.bg
}
if other.ul != except {
dup.ul = other.ul
}
return dup
}
@@ -412,15 +458,21 @@ func (p ColorPair) WithAttr(attr Attr) ColorPair {
return dup
}
func (p ColorPair) WithNewAttr(attr Attr) ColorPair {
dup := p
dup.attr = attr
return dup
}
func (p ColorPair) WithFg(fg ColorAttr) ColorPair {
dup := p
fgPair := ColorPair{fg.Color, colUndefined, fg.Attr}
fgPair := ColorPair{fg.Color, colUndefined, colUndefined, fg.Attr}
return dup.Merge(fgPair)
}
func (p ColorPair) WithBg(bg ColorAttr) ColorPair {
dup := p
bgPair := ColorPair{colUndefined, bg.Color, bg.Attr}
bgPair := ColorPair{colUndefined, bg.Color, colUndefined, bg.Attr}
return dup.Merge(bgPair)
}
@@ -455,6 +507,7 @@ type ColorTheme struct {
PreviewBg ColorAttr
DarkBg ColorAttr
Gutter ColorAttr
AltGutter ColorAttr
Prompt ColorAttr
InputBg ColorAttr
InputBorder ColorAttr
@@ -464,7 +517,7 @@ type ColorTheme struct {
CurrentMatch ColorAttr
Spinner ColorAttr
Info ColorAttr
Cursor ColorAttr
Pointer ColorAttr
Marker ColorAttr
Header ColorAttr
HeaderBg ColorAttr
@@ -484,6 +537,8 @@ type ColorTheme struct {
ListLabel ColorAttr
ListBorder ColorAttr
GapLine ColorAttr
NthCurrentAttr Attr // raw current-fg attr (before fg merge) for nth overlay
NthSelectedAttr Attr // raw selected-fg attr (before ListFg inherit) for nth overlay
}
type Event struct {
@@ -551,11 +606,13 @@ const (
BorderBottom
BorderLeft
BorderRight
BorderInline
BorderDashed
)
func (s BorderShape) HasLeft() bool {
switch s {
case BorderNone, BorderPhantom, BorderLine, BorderRight, BorderTop, BorderBottom, BorderHorizontal: // No Left
case BorderNone, BorderPhantom, BorderLine, BorderInline, BorderRight, BorderTop, BorderBottom, BorderHorizontal: // No Left
return false
}
return true
@@ -563,7 +620,7 @@ func (s BorderShape) HasLeft() bool {
func (s BorderShape) HasRight() bool {
switch s {
case BorderNone, BorderPhantom, BorderLine, BorderLeft, BorderTop, BorderBottom, BorderHorizontal: // No right
case BorderNone, BorderPhantom, BorderLine, BorderInline, BorderLeft, BorderTop, BorderBottom, BorderHorizontal: // No right
return false
}
return true
@@ -571,7 +628,7 @@ func (s BorderShape) HasRight() bool {
func (s BorderShape) HasTop() bool {
switch s {
case BorderNone, BorderPhantom, BorderLine, BorderLeft, BorderRight, BorderBottom, BorderVertical: // No top
case BorderNone, BorderPhantom, BorderLine, BorderInline, BorderLeft, BorderRight, BorderBottom, BorderVertical: // No top
return false
}
return true
@@ -579,7 +636,7 @@ func (s BorderShape) HasTop() bool {
func (s BorderShape) HasBottom() bool {
switch s {
case BorderNone, BorderPhantom, BorderLine, BorderLeft, BorderRight, BorderTop, BorderVertical: // No bottom
case BorderNone, BorderPhantom, BorderLine, BorderInline, BorderLeft, BorderRight, BorderTop, BorderVertical: // No bottom
return false
}
return true
@@ -599,6 +656,8 @@ type BorderStyle struct {
topRight rune
bottomLeft rune
bottomRight rune
leftMid rune
rightMid rune
}
type BorderCharacter int
@@ -614,7 +673,9 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
topLeft: ' ',
topRight: ' ',
bottomLeft: ' ',
bottomRight: ' '}
bottomRight: ' ',
leftMid: ' ',
rightMid: ' '}
}
if !unicode {
return BorderStyle{
@@ -627,6 +688,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
topRight: '+',
bottomLeft: '+',
bottomRight: '+',
leftMid: '+',
rightMid: '+',
}
}
switch shape {
@@ -641,6 +704,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
topRight: '┐',
bottomLeft: '└',
bottomRight: '┘',
leftMid: '├',
rightMid: '┤',
}
case BorderBold:
return BorderStyle{
@@ -653,6 +718,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
topRight: '┓',
bottomLeft: '┗',
bottomRight: '┛',
leftMid: '┣',
rightMid: '┫',
}
case BorderBlock:
// ▛▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▜
@@ -668,6 +735,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
topRight: '▜',
bottomLeft: '▙',
bottomRight: '▟',
leftMid: '▌',
rightMid: '▐',
}
case BorderThinBlock:
@@ -684,6 +753,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
topRight: '🭾',
bottomLeft: '🭼',
bottomRight: '🭿',
leftMid: '▏',
rightMid: '▕',
}
case BorderDouble:
@@ -697,6 +768,25 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
topRight: '╗',
bottomLeft: '╚',
bottomRight: '╝',
leftMid: '╠',
rightMid: '╣',
}
case BorderDashed:
// Terminal cells are taller than wide (~2:1), so horizontals can use a
// sparse stub per cell while verticals need more dashes per cell to look
// evenly dashed. Rounded corners and sharp T-junction mids.
return BorderStyle{
shape: shape,
top: '╶',
bottom: '╶',
left: '┆',
right: '┆',
topLeft: '╭',
topRight: '╮',
bottomLeft: '╰',
bottomRight: '╯',
leftMid: '├',
rightMid: '┤',
}
}
return BorderStyle{
@@ -709,6 +799,8 @@ func MakeBorderStyle(shape BorderShape, unicode bool) BorderStyle {
topRight: '╮',
bottomLeft: '╰',
bottomRight: '╯',
leftMid: '├',
rightMid: '┤',
}
}
@@ -730,6 +822,35 @@ const (
WindowFooter
)
// BorderColor returns the ColorPair used to draw borders for the given WindowType.
func BorderColor(wt WindowType) ColorPair {
switch wt {
case WindowList:
return ColListBorder
case WindowInput:
return ColInputBorder
case WindowHeader:
return ColHeaderBorder
case WindowFooter:
return ColFooterBorder
case WindowPreview:
return ColPreviewBorder
}
return ColBorder
}
// SectionEdge selects which outer edge of the frame an inline section
// should claim when PaintSectionFrame overpaints its adjacent border.
// SectionEdgeNone paints only the inner verticals (for sections that
// don't touch the outer top or bottom).
type SectionEdge int
const (
SectionEdgeNone SectionEdge = iota
SectionEdgeTop
SectionEdgeBottom
)
type Renderer interface {
DefaultTheme() *ColorTheme
Init() error
@@ -747,7 +868,8 @@ type Renderer interface {
HideCursor()
ShowCursor()
GetChar() Event
GetChar(cancellable bool) Event
CancelGetChar()
Top() int
MaxX() int
@@ -766,6 +888,19 @@ type Window interface {
DrawBorder()
DrawHBorder()
// DrawHSeparator draws an inline horizontal separator at `row` (relative to the
// window's top) using the color for `windowType`. The separator is conceptually
// the section's inner edge (e.g. the bottom border of an inline header), so the
// whole row including junctions carries the section's fg + bg. When useBottom is
// true the `bottom` horizontal char is used instead of `top`; for thinblock/block
// styles this keeps the thin line bonded to the list content on the opposite side.
DrawHSeparator(row int, windowType WindowType, useBottom bool)
// PaintSectionFrame overpaints the border cells around the rows [topContent,
// bottomContent] (inclusive, relative to the window's top) with the color for
// `windowType`. When edge is SectionEdgeTop / SectionEdgeBottom, the
// corresponding outer horizontal (+ corners) is also painted, letting the
// inline section claim that edge of the outer frame.
PaintSectionFrame(topContent, bottomContent int, windowType WindowType, edge SectionEdge)
Refresh()
FinishFill()
@@ -780,7 +915,7 @@ type Window interface {
Print(text string)
CPrint(color ColorPair, text string)
Fill(text string) FillReturn
CFill(fg Color, bg Color, attr Attr, text string) FillReturn
CFill(fg Color, bg Color, ul Color, attr Attr, text string) FillReturn
LinkBegin(uri string, params string)
LinkEnd()
Erase()
@@ -793,16 +928,18 @@ type FullscreenRenderer struct {
theme *ColorTheme
mouse bool
forceBlack bool
tabstop int
prevDownTime time.Time
clicks [][2]int
showCursor bool
}
func NewFullscreenRenderer(theme *ColorTheme, forceBlack bool, mouse bool) Renderer {
func NewFullscreenRenderer(theme *ColorTheme, forceBlack bool, mouse bool, tabstop int) Renderer {
r := &FullscreenRenderer{
theme: theme,
mouse: mouse,
forceBlack: forceBlack,
tabstop: tabstop,
prevDownTime: time.Unix(0, 0),
clicks: [][2]int{},
showCursor: true}
@@ -822,16 +959,18 @@ var (
ColDisabled ColorPair
ColGhost ColorPair
ColMatch ColorPair
ColCursor ColorPair
ColCursorEmpty ColorPair
ColCursorEmptyChar ColorPair
ColPointer ColorPair
ColPointerEmpty ColorPair
ColPointerEmptyChar ColorPair
ColAltPointerEmpty ColorPair
ColAltPointerEmptyChar ColorPair
ColMarker ColorPair
ColSelected ColorPair
ColSelectedMatch ColorPair
ColCurrent ColorPair
ColCurrentMatch ColorPair
ColCurrentCursor ColorPair
ColCurrentCursorEmpty ColorPair
ColCurrentPointer ColorPair
ColCurrentPointerEmpty ColorPair
ColCurrentMarker ColorPair
ColCurrentSelectedEmpty ColorPair
ColSpinner ColorPair
@@ -863,50 +1002,56 @@ func init() {
undefined := ColorAttr{colUndefined, AttrUndefined}
NoColorTheme = &ColorTheme{
Colored: false,
Input: defaultColor,
Fg: defaultColor,
Bg: defaultColor,
ListFg: defaultColor,
ListBg: defaultColor,
Colored: false,
// Root colors. Everything else is left undefined so that overriding a
// root (e.g. --color bw,bg:blue) propagates to the derived colors,
// just like in the colored base themes.
Input: defaultColor,
Fg: defaultColor,
Bg: defaultColor,
DarkBg: defaultColor,
Prompt: defaultColor,
Match: defaultColor,
Spinner: defaultColor,
Info: defaultColor,
Pointer: defaultColor,
Marker: defaultColor,
Header: defaultColor,
Footer: defaultColor,
BorderLabel: defaultColor,
// Derived colors. Left undefined so they inherit from a root.
ListFg: undefined,
ListBg: undefined,
AltBg: undefined,
SelectedFg: defaultColor,
SelectedBg: defaultColor,
SelectedMatch: defaultColor,
DarkBg: defaultColor,
Prompt: defaultColor,
Match: defaultColor,
SelectedFg: undefined,
SelectedBg: undefined,
SelectedMatch: undefined,
Current: undefined,
CurrentMatch: undefined,
Spinner: defaultColor,
Info: defaultColor,
Cursor: defaultColor,
Marker: defaultColor,
Header: defaultColor,
Border: undefined,
BorderLabel: defaultColor,
Ghost: undefined,
Disabled: defaultColor,
PreviewFg: defaultColor,
PreviewBg: defaultColor,
Disabled: undefined,
PreviewFg: undefined,
PreviewBg: undefined,
Gutter: undefined,
PreviewBorder: defaultColor,
PreviewScrollbar: defaultColor,
PreviewLabel: defaultColor,
ListLabel: defaultColor,
ListBorder: defaultColor,
Separator: defaultColor,
Scrollbar: defaultColor,
InputBg: defaultColor,
InputBorder: defaultColor,
InputLabel: defaultColor,
HeaderBg: defaultColor,
HeaderBorder: defaultColor,
HeaderLabel: defaultColor,
FooterBg: defaultColor,
FooterBorder: defaultColor,
FooterLabel: defaultColor,
GapLine: defaultColor,
AltGutter: undefined,
PreviewBorder: undefined,
PreviewScrollbar: undefined,
PreviewLabel: undefined,
ListLabel: undefined,
ListBorder: undefined,
Separator: undefined,
Scrollbar: undefined,
InputBg: undefined,
InputBorder: undefined,
InputLabel: undefined,
HeaderBg: undefined,
HeaderBorder: undefined,
HeaderLabel: undefined,
FooterBg: undefined,
FooterBorder: undefined,
FooterLabel: undefined,
GapLine: undefined,
Nth: undefined,
Nomatch: undefined,
}
@@ -929,7 +1074,7 @@ func init() {
CurrentMatch: undefined,
Spinner: undefined,
Info: undefined,
Cursor: undefined,
Pointer: undefined,
Marker: undefined,
Header: undefined,
Footer: undefined,
@@ -942,6 +1087,7 @@ func init() {
PreviewFg: undefined,
PreviewBg: undefined,
Gutter: undefined,
AltGutter: undefined,
PreviewBorder: undefined,
PreviewScrollbar: undefined,
PreviewLabel: undefined,
@@ -979,7 +1125,7 @@ func init() {
CurrentMatch: ColorAttr{colBrightGreen, AttrUndefined},
Spinner: ColorAttr{colGreen, AttrUndefined},
Info: ColorAttr{colYellow, AttrUndefined},
Cursor: ColorAttr{colRed, AttrUndefined},
Pointer: ColorAttr{colRed, AttrUndefined},
Marker: ColorAttr{colMagenta, AttrUndefined},
Header: ColorAttr{colCyan, AttrUndefined},
Footer: ColorAttr{colCyan, AttrUndefined},
@@ -990,6 +1136,7 @@ func init() {
PreviewFg: undefined,
PreviewBg: undefined,
Gutter: undefined,
AltGutter: undefined,
PreviewBorder: undefined,
PreviewScrollbar: undefined,
PreviewLabel: undefined,
@@ -1029,7 +1176,7 @@ func init() {
CurrentMatch: ColorAttr{151, AttrUndefined},
Spinner: ColorAttr{148, AttrUndefined},
Info: ColorAttr{144, AttrUndefined},
Cursor: ColorAttr{161, AttrUndefined},
Pointer: ColorAttr{161, AttrUndefined},
Marker: ColorAttr{168, AttrUndefined},
Header: ColorAttr{109, AttrUndefined},
Footer: ColorAttr{109, AttrUndefined},
@@ -1040,6 +1187,7 @@ func init() {
PreviewFg: undefined,
PreviewBg: undefined,
Gutter: undefined,
AltGutter: undefined,
PreviewBorder: undefined,
PreviewScrollbar: undefined,
PreviewLabel: undefined,
@@ -1079,7 +1227,7 @@ func init() {
CurrentMatch: ColorAttr{23, AttrUndefined},
Spinner: ColorAttr{65, AttrUndefined},
Info: ColorAttr{101, AttrUndefined},
Cursor: ColorAttr{161, AttrUndefined},
Pointer: ColorAttr{161, AttrUndefined},
Marker: ColorAttr{168, AttrUndefined},
Header: ColorAttr{31, AttrUndefined},
Footer: ColorAttr{31, AttrUndefined},
@@ -1090,6 +1238,7 @@ func init() {
PreviewFg: undefined,
PreviewBg: undefined,
Gutter: undefined,
AltGutter: undefined,
PreviewBorder: undefined,
PreviewScrollbar: undefined,
PreviewLabel: undefined,
@@ -1112,7 +1261,7 @@ func init() {
}
}
func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlack bool, hasInputWindow bool, hasHeaderWindow bool) {
func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlack bool, hasInputWindow bool, hasHeaderWindow bool, headerInline bool, footerInline bool) {
if forceBlack {
theme.Bg = ColorAttr{colBlack, AttrUndefined}
}
@@ -1129,7 +1278,7 @@ func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlac
theme.CurrentMatch = boldify(theme.CurrentMatch)
theme.Prompt = boldify(theme.Prompt)
theme.Input = boldify(theme.Input)
theme.Cursor = boldify(theme.Cursor)
theme.Pointer = boldify(theme.Pointer)
theme.Spinner = boldify(theme.Spinner)
}
@@ -1153,13 +1302,19 @@ func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlac
match.Attr = Underline
}
theme.Match = o(baseTheme.Match, match)
// Inherit from 'fg', so that we don't have to write 'current-fg:dim'
// These colors are not defined in the base themes.
// Resolve ListFg/ListBg early so Current and Selected can inherit from them.
theme.ListFg = o(theme.Fg, theme.ListFg)
theme.ListBg = o(theme.Bg, theme.ListBg)
// Inherit from 'list-fg', so that we don't have to write 'current-fg:dim'
// e.g. fzf --delimiter / --nth -1 --color fg:dim,nth:regular
current := theme.Current
if !baseTheme.Colored && current.IsUndefined() {
current.Attr |= Reverse
}
theme.Current = theme.Fg.Merge(o(baseTheme.Current, current))
resolvedCurrent := o(baseTheme.Current, current)
theme.NthCurrentAttr = resolvedCurrent.Attr
theme.Current = theme.ListFg.Merge(resolvedCurrent)
currentMatch := theme.CurrentMatch
if !baseTheme.Colored && currentMatch.IsUndefined() {
currentMatch.Attr |= Reverse | Underline
@@ -1167,7 +1322,7 @@ func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlac
theme.CurrentMatch = o(baseTheme.CurrentMatch, currentMatch)
theme.Spinner = o(baseTheme.Spinner, theme.Spinner)
theme.Info = o(baseTheme.Info, theme.Info)
theme.Cursor = o(baseTheme.Cursor, theme.Cursor)
theme.Pointer = o(baseTheme.Pointer, theme.Pointer)
theme.Marker = o(baseTheme.Marker, theme.Marker)
theme.Header = o(baseTheme.Header, theme.Header)
theme.Footer = o(baseTheme.Footer, theme.Footer)
@@ -1184,10 +1339,8 @@ func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlac
scrollbarDefined := theme.Scrollbar != undefined
previewBorderDefined := theme.PreviewBorder != undefined
// These colors are not defined in the base themes
theme.ListFg = o(theme.Fg, theme.ListFg)
theme.ListBg = o(theme.Bg, theme.ListBg)
theme.SelectedFg = o(theme.ListFg, theme.SelectedFg)
theme.NthSelectedAttr = theme.SelectedFg.Attr
theme.SelectedFg = theme.ListFg.Merge(theme.SelectedFg)
theme.SelectedBg = o(theme.ListBg, theme.SelectedBg)
theme.SelectedMatch = o(theme.Match, theme.SelectedMatch)
@@ -1207,6 +1360,7 @@ func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlac
gutter.Attr = Dim
}
theme.Gutter = o(theme.DarkBg, gutter)
theme.AltGutter = o(theme.Gutter, theme.AltGutter)
theme.PreviewFg = o(theme.Fg, theme.PreviewFg)
theme.PreviewBg = o(theme.Bg, theme.PreviewBg)
theme.PreviewLabel = o(theme.BorderLabel, theme.PreviewLabel)
@@ -1241,11 +1395,22 @@ func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlac
} else {
theme.HeaderBg = o(theme.Bg, theme.ListBg)
}
theme.HeaderBorder = o(theme.Border, theme.HeaderBorder)
// Inline header/footer borders sit inside the list frame, so default their color
// to the list-border color when the user has not explicitly set it. The inline
// separator then matches the surrounding frame.
headerBorderFallback := theme.Border
if headerInline {
headerBorderFallback = theme.ListBorder
}
theme.HeaderBorder = o(headerBorderFallback, theme.HeaderBorder)
theme.HeaderLabel = o(theme.BorderLabel, theme.HeaderLabel)
theme.FooterBg = o(theme.Bg, theme.FooterBg)
theme.FooterBorder = o(theme.Border, theme.FooterBorder)
footerBorderFallback := theme.Border
if footerInline {
footerBorderFallback = theme.ListBorder
}
theme.FooterBorder = o(footerBorderFallback, theme.FooterBorder)
theme.FooterLabel = o(theme.BorderLabel, theme.FooterLabel)
if theme.Nomatch.IsUndefined() {
@@ -1260,7 +1425,7 @@ func initPalette(theme *ColorTheme) {
if fg.Color == colDefault && (fg.Attr&Reverse) > 0 {
bg.Color = colDefault
}
return ColorPair{fg.Color, bg.Color, fg.Attr}
return ColorPair{fg.Color, bg.Color, colDefault, fg.Attr}
}
blank := theme.ListFg
blank.Attr = AttrRegular
@@ -1273,9 +1438,11 @@ func initPalette(theme *ColorTheme) {
ColDisabled = pair(theme.Disabled, theme.InputBg)
ColMatch = pair(theme.Match, theme.ListBg)
ColSelectedMatch = pair(theme.SelectedMatch, theme.SelectedBg)
ColCursor = pair(theme.Cursor, theme.Gutter)
ColCursorEmpty = pair(blank, theme.Gutter)
ColCursorEmptyChar = pair(theme.Gutter, theme.ListBg)
ColPointer = pair(theme.Pointer, theme.Gutter)
ColPointerEmpty = pair(blank, theme.Gutter)
ColPointerEmptyChar = pair(theme.Gutter, theme.ListBg)
ColAltPointerEmpty = pair(blank, theme.AltGutter)
ColAltPointerEmptyChar = pair(theme.AltGutter, theme.ListBg)
if theme.SelectedBg.Color != theme.ListBg.Color {
ColMarker = pair(theme.Marker, theme.SelectedBg)
} else {
@@ -1283,8 +1450,8 @@ func initPalette(theme *ColorTheme) {
}
ColCurrent = pair(theme.Current, theme.DarkBg)
ColCurrentMatch = pair(theme.CurrentMatch, theme.DarkBg)
ColCurrentCursor = pair(theme.Cursor, theme.DarkBg)
ColCurrentCursorEmpty = pair(blank, theme.DarkBg)
ColCurrentPointer = pair(theme.Pointer, theme.DarkBg)
ColCurrentPointerEmpty = pair(blank, theme.DarkBg)
ColCurrentMarker = pair(theme.Marker, theme.DarkBg)
ColCurrentSelectedEmpty = pair(blank, theme.DarkBg)
ColSpinner = pair(theme.Spinner, theme.InputBg)
@@ -1314,3 +1481,46 @@ func initPalette(theme *ColorTheme) {
func runeWidth(r rune) int {
return uniseg.StringWidth(string(r))
}
// WrappedLine represents a single visual line after character-level wrapping.
type WrappedLine struct {
Text string
DisplayWidth int
}
// WrapLine splits a single line (no embedded \n) into visual lines
// that fit within initialMax columns. Character-level wrapping only.
func WrapLine(input string, prefixLength int, initialMax int, tabstop int, wrapSignWidth int) []WrappedLine {
lines := []WrappedLine{}
width := 0
line := ""
gr := uniseg.NewGraphemes(input)
maxWidth := initialMax
contMax := max(1, initialMax-wrapSignWidth)
for gr.Next() {
rs := gr.Runes()
str := string(rs)
var w int
if len(rs) == 1 && rs[0] == '\t' {
w = tabstop - (prefixLength+width)%tabstop
str = strings.Repeat(" ", w)
} else if rs[0] == '\r' {
w++
} else {
w = uniseg.StringWidth(str)
}
width += w
if prefixLength+width <= maxWidth {
line += str
} else {
lines = append(lines, WrappedLine{string(line), width - w})
line = str
prefixLength = 0
width = w
maxWidth = contMax
}
}
lines = append(lines, WrappedLine{string(line), width})
return lines
}
+40
View File
@@ -2,6 +2,46 @@ package tui
import "testing"
func TestWrapLine(t *testing.T) {
// Basic wrapping
lines := WrapLine("hello world", 0, 7, 8, 2)
if len(lines) != 2 || lines[0].Text != "hello w" || lines[1].Text != "orld" {
t.Errorf("Basic wrap: %v", lines)
}
// Exact fit - no wrapping needed
lines = WrapLine("hello", 0, 5, 8, 2)
if len(lines) != 1 || lines[0].Text != "hello" || lines[0].DisplayWidth != 5 {
t.Errorf("Exact fit: %v", lines)
}
// With prefix length
lines = WrapLine("hello", 3, 5, 8, 2)
if len(lines) != 2 || lines[0].Text != "he" || lines[1].Text != "llo" {
t.Errorf("Prefix length: %v", lines)
}
// Empty string
lines = WrapLine("", 0, 10, 8, 2)
if len(lines) != 1 || lines[0].Text != "" || lines[0].DisplayWidth != 0 {
t.Errorf("Empty string: %v", lines)
}
// Continuation lines account for wrapSignWidth
lines = WrapLine("abcdefghij", 0, 5, 8, 2)
// First line: "abcde" (5 chars fit in width 5)
// Continuation max: 5-2=3, so "fgh" then "ij"
if len(lines) != 3 || lines[0].Text != "abcde" || lines[1].Text != "fgh" || lines[2].Text != "ij" {
t.Errorf("Continuation: %v", lines)
}
// Tab expansion
lines = WrapLine("\there", 0, 10, 4, 2)
if len(lines) != 1 || lines[0].DisplayWidth != 8 {
t.Errorf("Tab: %v", lines)
}
}
func TestHexToColor(t *testing.T) {
assert := func(expr string, r, g, b int) {
color := HexToColor(expr)
+1 -1
View File
@@ -8,7 +8,7 @@ import (
func TestAtExit(t *testing.T) {
want := []int{3, 2, 1, 0}
var called []int
for i := 0; i < 4; i++ {
for i := range 4 {
n := i
AtExit(func() { called = append(called, n) })
}
+17 -4
View File
@@ -52,7 +52,7 @@ func ToChars(bytes []byte) Chars {
}
runes := make([]rune, bytesUntil, len(bytes))
for i := 0; i < bytesUntil; i++ {
for i := range bytesUntil {
runes[i] = rune(bytes[i])
}
for i := bytesUntil; i < len(bytes); {
@@ -187,7 +187,7 @@ func (chars *Chars) TrailingWhitespaces() int {
func (chars *Chars) TrimTrailingWhitespaces(maxIndex int) {
whitespaces := chars.TrailingWhitespaces()
end := len(chars.slice) - whitespaces
chars.slice = chars.slice[0:Max(end, maxIndex)]
chars.slice = chars.slice[0:max(end, maxIndex)]
}
func (chars *Chars) TrimSuffix(runes []rune) {
@@ -249,7 +249,7 @@ func (chars *Chars) Prepend(prefix string) {
}
}
func (chars *Chars) Lines(multiLine bool, maxLines int, wrapCols int, wrapSignWidth int, tabstop int) ([][]rune, bool) {
func (chars *Chars) Lines(multiLine bool, maxLines int, wrapCols int, wrapSignWidth int, tabstop int, wrapWord bool) ([][]rune, bool) {
text := make([]rune, chars.Length())
copy(text, chars.ToRunes())
@@ -259,7 +259,7 @@ func (chars *Chars) Lines(multiLine bool, maxLines int, wrapCols int, wrapSignWi
lines = append(lines, text)
} else {
from := 0
for off := 0; off < len(text); off++ {
for off := range text {
if text[off] == '\n' {
lines = append(lines, text[from:off+1]) // Include '\n'
from = off + 1
@@ -307,6 +307,19 @@ func (chars *Chars) Lines(multiLine bool, maxLines int, wrapCols int, wrapSignWi
if overflowIdx == 0 {
overflowIdx = 1
}
if wrapWord {
// Find last space/tab at or before overflowIdx
breakIdx := -1
for k := overflowIdx; k > 0; k-- {
if line[k-1] == ' ' || line[k-1] == '\t' {
breakIdx = k
break
}
}
if breakIdx > 0 {
overflowIdx = breakIdx
}
}
if len(wrapped) >= maxLines {
return wrapped, true
}
+49 -1
View File
@@ -51,7 +51,7 @@ func TestTrimLength(t *testing.T) {
func TestCharsLines(t *testing.T) {
chars := ToChars([]byte("abcdef\n가나다\n\tdef"))
check := func(multiLine bool, maxLines int, wrapCols int, wrapSignWidth int, tabstop int, expectedNumLines int, expectedOverflow bool) {
lines, overflow := chars.Lines(multiLine, maxLines, wrapCols, wrapSignWidth, tabstop)
lines, overflow := chars.Lines(multiLine, maxLines, wrapCols, wrapSignWidth, tabstop, false)
fmt.Println(lines, overflow)
if len(lines) != expectedNumLines || overflow != expectedOverflow {
t.Errorf("Invalid result: %d %v (expected %d %v)", len(lines), overflow, expectedNumLines, expectedOverflow)
@@ -81,3 +81,51 @@ func TestCharsLines(t *testing.T) {
// With wrap sign (3 + 2) and no multi-line
check(false, 100, 3, 2, 1, 13, false)
}
func TestCharsLinesWrapWord(t *testing.T) {
// "hello world foo bar" with width 12 should break at word boundaries
chars := ToChars([]byte("hello world foo bar"))
lines, overflow := chars.Lines(false, 100, 12, 0, 8, true)
// "hello world " (12) | "foo bar" (7)
if len(lines) != 2 || overflow {
t.Errorf("Expected 2 lines, got %d (overflow: %v): %v", len(lines), overflow, lines)
}
if string(lines[0]) != "hello world " {
t.Errorf("Expected first line 'hello world ', got %q", string(lines[0]))
}
if string(lines[1]) != "foo bar" {
t.Errorf("Expected second line 'foo bar', got %q", string(lines[1]))
}
// No word boundary: a single long word falls back to character wrap
chars2 := ToChars([]byte("abcdefghijklmnop"))
lines2, _ := chars2.Lines(false, 100, 10, 0, 8, true)
if len(lines2) != 2 {
t.Errorf("Expected 2 lines for long word, got %d: %v", len(lines2), lines2)
}
if string(lines2[0]) != "abcdefghij" {
t.Errorf("Expected first line 'abcdefghij', got %q", string(lines2[0]))
}
// Tab as word boundary
chars3 := ToChars([]byte("hello\tworld"))
lines3, _ := chars3.Lines(false, 100, 7, 0, 8, true)
// "hello\t" should break at tab (width of tab at pos 5 with tabstop 8 = 3, total width = 8 > 7)
// Actually RunesWidth: 'h'=1,'e'=1,'l'=1,'l'=1,'o'=1,'\t'=3 = 8 > 7, overflowIdx=5
// Then word-wrap scans back and finds no space/tab before idx 5 (tab IS at idx 5 but we check line[k-1])
// Wait - let me think: overflowIdx=5, we check k=5 -> line[4]='o', k=4 -> line[3]='l'... no space/tab found
// Falls back to character wrap: "hello" | "\tworld"
if len(lines3) < 2 {
t.Errorf("Expected at least 2 lines for tab test, got %d: %v", len(lines3), lines3)
}
// wrapWord=false still character-wraps
chars4 := ToChars([]byte("hello world"))
lines4, _ := chars4.Lines(false, 100, 8, 0, 8, false)
if len(lines4) != 2 {
t.Errorf("Expected 2 lines with wrapWord=false, got %d: %v", len(lines4), lines4)
}
if string(lines4[0]) != "hello wo" {
t.Errorf("Expected first line 'hello wo', got %q", string(lines4[0]))
}
}
+10 -63
View File
@@ -1,11 +1,11 @@
package util
import (
"cmp"
"math"
"os"
"strconv"
"strings"
"time"
"github.com/mattn/go-isatty"
"github.com/rivo/uniseg"
@@ -18,8 +18,13 @@ func StringWidth(s string) int {
// RunesWidth returns runes width
func RunesWidth(runes []rune, prefixWidth int, tabstop int, limit int) (int, int) {
return StringsWidth(string(runes), prefixWidth, tabstop, limit)
}
// StringsWidth returns the width of the string
func StringsWidth(str string, prefixWidth int, tabstop int, limit int) (int, int) {
width := 0
gr := uniseg.NewGraphemes(string(runes))
gr := uniseg.NewGraphemes(str)
idx := 0
for gr.Next() {
rs := gr.Runes()
@@ -55,54 +60,8 @@ func Truncate(input string, limit int) ([]rune, int) {
return runes, width
}
// Max returns the largest integer
func Max(first int, second int) int {
if first >= second {
return first
}
return second
}
// Max16 returns the largest integer
func Max16(first int16, second int16) int16 {
if first >= second {
return first
}
return second
}
// Max32 returns the largest 32-bit integer
func Max32(first int32, second int32) int32 {
if first > second {
return first
}
return second
}
// Min returns the smallest integer
func Min(first int, second int) int {
if first <= second {
return first
}
return second
}
// Min32 returns the smallest 32-bit integer
func Min32(first int32, second int32) int32 {
if first <= second {
return first
}
return second
}
// Constrain32 limits the given 32-bit integer with the upper and lower bounds
func Constrain32(val int32, min int32, max int32) int32 {
return Max32(Min32(val, max), min)
}
// Constrain limits the given integer with the upper and lower bounds
func Constrain(val int, min int, max int) int {
return Max(Min(val, max), min)
func Constrain[T cmp.Ordered](val, minimum, maximum T) T {
return max(min(val, maximum), minimum)
}
func AsUint16(val int) uint16 {
@@ -114,18 +73,6 @@ func AsUint16(val int) uint16 {
return uint16(val)
}
// DurWithin limits the given time.Duration with the upper and lower bounds
func DurWithin(
val time.Duration, min time.Duration, max time.Duration) time.Duration {
if val < min {
return min
}
if val > max {
return max
}
return val
}
// IsTty returns true if the file is a terminal
func IsTty(file *os.File) bool {
fd := file.Fd()
@@ -197,7 +144,7 @@ func CompareVersions(v1, v2 string) int {
return n
}
for i := 0; i < Max(len(parts1), len(parts2)); i++ {
for i := 0; i < max(len(parts1), len(parts2)); i++ {
var p1, p2 int
if i < len(parts1) {
p1 = atoi(parts1[i])
-92
View File
@@ -4,72 +4,8 @@ import (
"math"
"strings"
"testing"
"time"
)
func TestMax(t *testing.T) {
if Max(10, 1) != 10 {
t.Error("Expected", 10)
}
if Max(-2, 5) != 5 {
t.Error("Expected", 5)
}
}
func TestMax16(t *testing.T) {
if Max16(10, 1) != 10 {
t.Error("Expected", 10)
}
if Max16(-2, 5) != 5 {
t.Error("Expected", 5)
}
if Max16(math.MaxInt16, 0) != math.MaxInt16 {
t.Error("Expected", math.MaxInt16)
}
if Max16(0, math.MinInt16) != 0 {
t.Error("Expected", 0)
}
}
func TestMax32(t *testing.T) {
if Max32(10, 1) != 10 {
t.Error("Expected", 10)
}
if Max32(-2, 5) != 5 {
t.Error("Expected", 5)
}
if Max32(math.MaxInt32, 0) != math.MaxInt32 {
t.Error("Expected", math.MaxInt32)
}
if Max32(0, math.MinInt32) != 0 {
t.Error("Expected", 0)
}
}
func TestMin(t *testing.T) {
if Min(10, 1) != 1 {
t.Error("Expected", 1)
}
if Min(-2, 5) != -2 {
t.Error("Expected", -2)
}
}
func TestMin32(t *testing.T) {
if Min32(10, 1) != 1 {
t.Error("Expected", 1)
}
if Min32(-2, 5) != -2 {
t.Error("Expected", -2)
}
if Min32(math.MaxInt32, 0) != 0 {
t.Error("Expected", 0)
}
if Min32(0, math.MinInt32) != math.MinInt32 {
t.Error("Expected", math.MinInt32)
}
}
func TestConstrain(t *testing.T) {
if Constrain(-3, -1, 3) != -1 {
t.Error("Expected", -1)
@@ -83,22 +19,6 @@ func TestConstrain(t *testing.T) {
}
}
func TestConstrain32(t *testing.T) {
if Constrain32(-3, -1, 3) != -1 {
t.Error("Expected", -1)
}
if Constrain32(2, -1, 3) != 2 {
t.Error("Expected", 2)
}
if Constrain32(5, -1, 3) != 3 {
t.Error("Expected", 3)
}
if Constrain32(0, math.MinInt32, math.MaxInt32) != 0 {
t.Error("Expected", 0)
}
}
func TestAsUint16(t *testing.T) {
if AsUint16(5) != 5 {
t.Error("Expected", 5)
@@ -120,18 +40,6 @@ func TestAsUint16(t *testing.T) {
}
}
func TestDurWithIn(t *testing.T) {
if DurWithin(time.Duration(5), time.Duration(1), time.Duration(8)) != time.Duration(5) {
t.Error("Expected", time.Duration(0))
}
if DurWithin(time.Duration(0)*time.Second, time.Second, time.Duration(3)*time.Second) != time.Second {
t.Error("Expected", time.Second)
}
if DurWithin(time.Duration(10)*time.Second, time.Duration(0), time.Second) != time.Second {
t.Error("Expected", time.Second)
}
}
func TestOnce(t *testing.T) {
o := Once(false)
if o() {
+3 -2
View File
@@ -9,6 +9,7 @@ import (
"strings"
"syscall"
"github.com/junegunn/go-shellwords"
"golang.org/x/sys/unix"
)
@@ -20,8 +21,8 @@ type Executor struct {
func NewExecutor(withShell string) *Executor {
shell := os.Getenv("SHELL")
args := strings.Fields(withShell)
if len(args) > 0 {
args, err := shellwords.Parse(withShell)
if err == nil && len(args) > 0 {
shell = args[0]
args = args[1:]
} else {
+44 -7
View File
@@ -11,6 +11,8 @@ import (
"strings"
"sync/atomic"
"syscall"
"golang.org/x/sys/windows"
)
type shellType int
@@ -19,6 +21,7 @@ const (
shellTypeUnknown shellType = iota
shellTypeCmd
shellTypePowerShell
shellTypePwsh
)
var escapeRegex = regexp.MustCompile(`[&|<>()^%!"]`)
@@ -46,7 +49,10 @@ func NewExecutor(withShell string) *Executor {
} else if strings.HasPrefix(basename, "cmd") {
shellType = shellTypeCmd
args = []string{"/s/c"}
} else if strings.HasPrefix(basename, "pwsh") || strings.HasPrefix(basename, "powershell") {
} else if strings.HasPrefix(basename, "pwsh") {
shellType = shellTypePwsh
args = []string{"-NoProfile", "-Command"}
} else if strings.HasPrefix(basename, "powershell") {
shellType = shellTypePowerShell
args = []string{"-NoProfile", "-Command"}
} else {
@@ -56,8 +62,12 @@ func NewExecutor(withShell string) *Executor {
}
// ExecCommand executes the given command with $SHELL
// FIXME: setpgid is unused. We set it in the Unix implementation so that we
// can kill preview process with its child processes at once.
//
// On Windows, setpgid controls whether the spawned process is placed in a new
// process group (so that it can be signaled independently, e.g. for previews).
// However, we only do this for "pwsh" and non-standard shells, because cmd.exe
// and Windows PowerShell ("powershell.exe") don't always exit on Ctrl-Break.
//
// NOTE: For "powershell", we should ideally set output encoding to UTF8,
// but it is left as is now because no adverse effect has been observed.
func (x *Executor) ExecCommand(command string, setpgid bool) *exec.Cmd {
@@ -73,19 +83,31 @@ func (x *Executor) ExecCommand(command string, setpgid bool) *exec.Cmd {
}
x.shellPath.Store(shell)
}
var creationFlags uint32
// Set new process group for pwsh (PowerShell 7+) and unknown/posix-ish shells
if setpgid && (x.shellType == shellTypePwsh || x.shellType == shellTypeUnknown) {
creationFlags = windows.CREATE_NEW_PROCESS_GROUP
}
var cmd *exec.Cmd
if x.shellType == shellTypeCmd {
cmd = exec.Command(shell)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: false,
CmdLine: fmt.Sprintf(`%s "%s"`, strings.Join(x.args, " "), command),
CreationFlags: 0,
CreationFlags: creationFlags,
}
} else {
cmd = exec.Command(shell, append(x.args, command)...)
args := x.args
if setpgid && x.shellType == shellTypePwsh {
// pwsh needs -NonInteractive flag to exit on Ctrl-Break
args = append([]string{"-NonInteractive"}, x.args...)
}
cmd = exec.Command(shell, append(args, command)...)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: false,
CreationFlags: 0,
CreationFlags: creationFlags,
}
}
return cmd
@@ -156,7 +178,7 @@ func (x *Executor) QuoteEntry(entry string) string {
fd -H --no-ignore -td -d 4 | fzf --preview ".\eza.exe --color=always --tree --level=3 --icons=always {}" --with-shell "powershell -NoProfile -Command"
*/
return escapeArg(entry)
case shellTypePowerShell:
case shellTypePowerShell, shellTypePwsh:
escaped := strings.ReplaceAll(entry, `"`, `\"`)
return "'" + strings.ReplaceAll(escaped, "'", "''") + "'"
default:
@@ -166,6 +188,21 @@ func (x *Executor) QuoteEntry(entry string) string {
// KillCommand kills the process for the given command
func KillCommand(cmd *exec.Cmd) error {
// Safely handle nil command or process.
if cmd == nil || cmd.Process == nil {
return nil
}
// If it has its own process group, we can send it Ctrl-Break
if cmd.SysProcAttr != nil && cmd.SysProcAttr.CreationFlags&windows.CREATE_NEW_PROCESS_GROUP != 0 {
if err := windows.GenerateConsoleCtrlEvent(windows.CTRL_BREAK_EVENT, uint32(cmd.Process.Pid)); err == nil {
return nil
}
}
// If it's the same process group, or if sending the console control event
// fails (e.g., no console, different console, or process already exited),
// fall back to a standard kill. This probably won't *help* if there's I/O
// going on, because Wait() will still hang until the I/O finishes unless we
// hard-kill the entire process group. But it doesn't hurt to try!
return cmd.Process.Kill()
}
+41
View File
@@ -0,0 +1,41 @@
package fzf
import (
"os/exec"
)
func runZellij(args []string, opts *Options) (int, error) {
argStr, dir := popupArgStr(args, opts)
zellijArgs := []string{
"run", "--floating", "--close-on-exit", "--block-until-exit",
"--cwd", dir,
}
if !opts.Tmux.border {
zellijArgs = append(zellijArgs, "--borderless", "true")
}
switch opts.Tmux.position {
case posUp:
zellijArgs = append(zellijArgs, "-y", "0")
case posDown:
zellijArgs = append(zellijArgs, "-y", "9999")
case posLeft:
zellijArgs = append(zellijArgs, "-x", "0")
case posRight:
zellijArgs = append(zellijArgs, "-x", "9999")
case posCenter:
// Zellij centers floating panes by default
}
zellijArgs = append(zellijArgs, "--width", opts.Tmux.width.String())
zellijArgs = append(zellijArgs, "--height", opts.Tmux.height.String())
zellijArgs = append(zellijArgs, "--")
return runProxy(argStr, func(temp string, needBash bool) (*exec.Cmd, error) {
sh, err := sh(needBash)
if err != nil {
return nil, err
}
zellijArgs = append(zellijArgs, sh, temp)
return exec.Command("zellij", zellijArgs...), nil
}, opts, true)
}

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