From d6a6c8586c474ab4483d06518cd92e612f6a3178 Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Tue, 1 Sep 2026 09:02:07 +0900 Subject: [PATCH] [vim] Open fzf in a tmux or Zellij floating pane by default The window fzf was started from stays visible, and can be used while fzf runs, which a popup inside Vim cannot offer. Only where the pane is not modal, so tmux 3.7 or above, or Zellij 0.44 or above with fzf 0.71.0 or above. A tmux popup from 3.3 to 3.6 cannot be left, and below 3.3 fzf goes through the fzf-tmux script, whose options are spelled differently. Both keep the window inside Vim. An explicit popup layout is unaffected and still works from 3.3. fzf checks tmux before Zellij, so tmux wins when both are set. g:fzf_layout still wins, so anyone who set it sees no change. --- CHANGELOG.md | 3 ++ README-VIM.md | 92 +++++++++++++++++++++++++++++++--------- doc/fzf.txt | 74 ++++++++++++++++++++++++-------- plugin/fzf.vim | 102 +++++++++++++++++++++++++++++++++++++++------ test/vim/fzf.vader | 27 ++++++++++++ 5 files changed, 249 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fa5790e..beb43563 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ CHANGELOG ```vim let g:fzf_layout = { 'popup': '90%,70%' } ``` + - fzf now opens in a tmux or Zellij floating pane by default, so the window it was started from stays visible and can be used while fzf is running + - Requires tmux 3.7+ or Zellij 0.44+ + - Set `g:fzf_layout` to pick a different layout 0.74.3 ------ diff --git a/README-VIM.md b/README-VIM.md index 70c5b37a..ba505e45 100644 --- a/README-VIM.md +++ b/README-VIM.md @@ -1,6 +1,26 @@ FZF Vim integration =================== + + +* [Installation](#installation) +* [Summary](#summary) +* [`:FZF[!]`](#fzf) + * [Configuration](#configuration) + * [Examples](#examples) + * [Explanation of `g:fzf_colors`](#explanation-of-gfzf_colors) +* [`fzf#run`](#fzfrun) +* [`fzf#wrap`](#fzfwrap) + * [Global options supported by `fzf#wrap`](#global-options-supported-by-fzfwrap) +* [Tips](#tips) + * [fzf inside terminal buffer](#fzf-inside-terminal-buffer) + * [Starting fzf in a Vim popup window](#starting-fzf-in-a-vim-popup-window) + * [Starting fzf in a tmux/Zellij popup window](#starting-fzf-in-a-tmuxzellij-popup-window) + * [Hide statusline](#hide-statusline) +* [License](#license) + + + Installation ------------ @@ -133,19 +153,35 @@ let g:fzf_action = { \ 'ctrl-v': 'vsplit' } " Default fzf layout -" - Popup window (center of the screen) -let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } } +if exists('$TMUX') || exists('$ZELLIJ') + " The Vim plugin will try to open fzf in a tmux or Zellij popup + " if possible (requires recent fzf and tmux/zellij) using --popup option, + " with the following argument: + let g:fzf_layout = { 'popup': '90%,60%' } +else + " If --popup option is not available, it will open in a popup window inside + " Vim (center of the screen) + let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } } +endif -" - Popup window (center of the current window) +" Here are some more layout examples: + +" - Tmux or Zellij popup at the bottom 40% +let g:fzf_layout = { 'popup': 'bottom,40%' } + +" - Tmux or Zellij popup at the top with a different size +let g:fzf_layout = { 'popup': 'top,90%,40%' } + +" - Vim popup window: at the center of the current window (relative) let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6, 'relative': v:true } } -" - Popup window (anchored to the bottom of the current window) +" - Vim popup window: anchored to the bottom of the current window let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6, 'relative': v:true, 'yoffset': 1.0 } } -" - down / up / left / right +" - Vim split window: down / up / left / right let g:fzf_layout = { 'down': '40%' } -" - Window using a Vim command +" - Vim window using a Vim command let g:fzf_layout = { 'window': 'enew' } let g:fzf_layout = { 'window': '-tabnew' } let g:fzf_layout = { 'window': '10new' } @@ -408,10 +444,10 @@ Tips ### fzf inside terminal buffer -On the latest versions of Vim and Neovim, fzf will start in a terminal buffer. -If you find the default ANSI colors to be different, consider configuring the -colors using `g:terminal_ansi_colors` in regular Vim or `g:terminal_color_x` -in Neovim. +When fzf is configured to start in a terminal buffer inside Vim or Neovim, you +may find the default ANSI colors to be different. In that case, configure the +colors using `g:terminal_ansi_colors` in regular Vim or `g:terminal_color_x` in +Neovim. ```vim " Terminal colors for seoul256 color scheme @@ -442,7 +478,10 @@ else endif ``` -### Starting fzf in a popup window +### Starting fzf in a Vim popup window + +You can configure fzf to start in a Vim popup window by setting the `window` key +in `g:fzf_layout`. ```vim " Required: @@ -458,18 +497,32 @@ endif let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } } ``` -Alternatively, you can make fzf open in a popup window (requires tmux 3.3 or -above, or Zellij 0.44 or above) by putting `--popup` option value in `popup` -key. `tmux` is accepted as a synonym, just as `--tmux` is an alias of -`--popup`. +### Starting fzf in a tmux/Zellij popup window + +fzf can also start in a popup of the multiplexer instead of a window inside +Vim, by putting a `--popup` option value in the `popup` key. `tmux` is +accepted as a synonym, just as `--tmux` is an alias of `--popup`. + +The layout works on tmux 3.3 or above, or on Zellij 0.44 or above with fzf +0.71.0 or above. It is the default on tmux 3.7 or above with fzf 0.74.0 or +above, and on Zellij, where the pane is not modal: Vim keeps the window fzf +was started from visible, and you can switch to it while fzf is open. Below +those versions tmux gives a popup that cannot be left, so a window inside Vim +is the default there. On tmux, an explicit `--border` style also gives a modal +popup rather than a floating pane, because the native border of a tmux +floating pane cannot be removed. Drop `--border` to keep the floating pane and +its native border. Zellij keeps the floating pane either way, and hides its +native border when fzf draws one. Set `g:fzf_layout` yourself to choose either +one. ```vim -" See `--popup` option in `man fzf` for available options -" [center|top|bottom|left|right][,SIZE[%]][,SIZE[%]] if exists('$TMUX') || exists('$ZELLIJ') + " See `--popup` option in `man fzf` for available options + " [center|top|bottom|left|right][,SIZE[%]][,SIZE[%]] let g:fzf_layout = { 'popup': '90%,70%' } else - let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } } + " Configure the Vim popup window in case not on the multiplexer + let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.7 } } endif ``` @@ -477,7 +530,8 @@ endif When fzf starts in a terminal buffer, the file type of the buffer is set to `fzf`. So you can set up `FileType fzf` autocmd to customize the settings of -the window. +the window. This applies to the layouts that open inside Vim, not to the tmux +or Zellij pane the default uses, which is not a buffer. For example, if you open fzf on the bottom on the screen (e.g. `{'down': '40%'}`), you might want to temporarily disable the statusline for a cleaner diff --git a/doc/fzf.txt b/doc/fzf.txt index 46206282..5828c1e4 100644 --- a/doc/fzf.txt +++ b/doc/fzf.txt @@ -14,7 +14,8 @@ FZF - TABLE OF CONTENTS *fzf* *fzf-to Global options supported by fzf#wrap |fzf-global-options-supported-by-fzf#wrap| Tips |fzf-tips| fzf inside terminal buffer |fzf-inside-terminal-buffer| - Starting fzf in a popup window |fzf-starting-fzf-in-a-popup-window| + Starting fzf in a Vim popup window |fzf-starting-fzf-in-a-vim-popup-window| + Starting fzf in a tmux/Zellij popup window |fzf-starting-fzf-in-a-tmuxzellij-popup-window| Hide statusline |fzf-hide-statusline| License |fzf-license| @@ -161,19 +162,35 @@ Examples~ \ 'ctrl-v': 'vsplit' } " Default fzf layout - " - Popup window (center of the screen) - let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } } + if exists('$TMUX') || exists('$ZELLIJ') + " The Vim plugin will try to open fzf in a tmux or Zellij popup + " if possible (requires recent fzf and tmux/zellij) using --popup option, + " with the following argument: + let g:fzf_layout = { 'popup': '90%,60%' } + else + " If --popup option is not available, it will open in a popup window inside + " Vim (center of the screen) + let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } } + endif - " - Popup window (center of the current window) + " Here are some more layout examples: + + " - Tmux or Zellij popup at the bottom 40% + let g:fzf_layout = { 'popup': 'bottom,40%' } + + " - Tmux or Zellij popup at the top with a different size + let g:fzf_layout = { 'popup': 'top,90%,40%' } + + " - Vim popup window: at the center of the current window (relative) let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6, 'relative': v:true } } - " - Popup window (anchored to the bottom of the current window) + " - Vim popup window: anchored to the bottom of the current window let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6, 'relative': v:true, 'yoffset': 1.0 } } - " - down / up / left / right + " - Vim split window: down / up / left / right let g:fzf_layout = { 'down': '40%' } - " - Window using a Vim command + " - Vim window using a Vim command let g:fzf_layout = { 'window': 'enew' } let g:fzf_layout = { 'window': '-tabnew' } let g:fzf_layout = { 'window': '10new' } @@ -418,8 +435,8 @@ TIPS *fzf-tips* *fzf-inside-terminal-buffer* -On the latest versions of Vim and Neovim, fzf will start in a terminal buffer. -If you find the default ANSI colors to be different, consider configuring the +When fzf is configured to start in a terminal buffer inside Vim or Neovim, you +may find the default ANSI colors to be different. In that case, configure the colors using `g:terminal_ansi_colors` in regular Vim or `g:terminal_color_x` in Neovim. @@ -452,8 +469,11 @@ in Neovim. endif < -< Starting fzf in a popup window >____________________________________________~ - *fzf-starting-fzf-in-a-popup-window* +< Starting fzf in a Vim popup window >________________________________________~ + *fzf-starting-fzf-in-a-vim-popup-window* + +You can configure fzf to start in a Vim popup window by setting the `window` +key in `g:fzf_layout`. > " Required: " - width [float range [0 ~ 1]] or [integer range [8 ~ ]] @@ -467,16 +487,33 @@ in Neovim. " - 'rounded' / 'sharp' / 'horizontal' / 'vertical' / 'top' / 'bottom' / 'left' / 'right' let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } } < -Alternatively, you can make fzf open in a popup window (requires tmux 3.3 or -above, or Zellij 0.44 or above) by putting `--popup` options in `popup` key. -`tmux` is accepted as a synonym, just as `--tmux` is an alias of `--popup`. + +< Starting fzf in a tmux/Zellij popup window >________________________________~ + *fzf-starting-fzf-in-a-tmuxzellij-popup-window* + +fzf can also start in a popup of the multiplexer instead of a window inside +Vim, by putting a `--popup` option value in the `popup` key. `tmux` is +accepted as a synonym, just as `--tmux` is an alias of `--popup`. + +The layout works on tmux 3.3 or above, or on Zellij 0.44 or above with fzf +0.71.0 or above. It is the default on tmux 3.7 or above with fzf 0.74.0 or +above, and on Zellij, where the pane is not modal: Vim keeps the window fzf +was started from visible, and you can switch to it while fzf is open. Below +those versions tmux gives a popup that cannot be left, so a window inside Vim +is the default there. On tmux, an explicit `--border` style also gives a modal +popup rather than a floating pane, because the native border of a tmux +floating pane cannot be removed. Drop `--border` to keep the floating pane and +its native border. Zellij keeps the floating pane either way, and hides its +native border when fzf draws one. Set `g:fzf_layout` yourself to choose either +one. > - " See `--popup` option in `man fzf` for available options - " [center|top|bottom|left|right][,SIZE[%]][,SIZE[%]] if exists('$TMUX') || exists('$ZELLIJ') + " See `--popup` option in `man fzf` for available options + " [center|top|bottom|left|right][,SIZE[%]][,SIZE[%]] let g:fzf_layout = { 'popup': '90%,70%' } else - let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } } + " Configure the Vim popup window in case not on the multiplexer + let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.7 } } endif < @@ -485,7 +522,8 @@ above, or Zellij 0.44 or above) by putting `--popup` options in `popup` key. When fzf starts in a terminal buffer, the file type of the buffer is set to `fzf`. So you can set up `FileType fzf` autocmd to customize the settings of -the window. +the window. This applies to the layouts that open inside Vim, not to the tmux +or Zellij pane the default uses, which is not a buffer. For example, if you open fzf on the bottom on the screen (e.g. `{'down': '40%'}`), you might want to temporarily disable the statusline for a cleaner diff --git a/plugin/fzf.vim b/plugin/fzf.vim index 59a540ef..e0dbd64f 100644 --- a/plugin/fzf.vim +++ b/plugin/fzf.vim @@ -140,6 +140,11 @@ function! s:popup_support() endfunction function! s:default_layout() + " A floating pane leaves the window fzf was started from visible and + " reachable while fzf is open. A popup covers it, inside Vim or not + if s:floating_pane() + return { 'tmux': '90%,60%' } + endif return s:popup_support() \ ? { 'window' : { 'width': 0.9, 'height': 0.6 } } \ : { 'down': '~40%' } @@ -165,6 +170,12 @@ function! fzf#install() if v:shell_error throw 'Failed to download fzf: '.script endif + + " A new binary invalidates the chosen executable and everything derived from + " its version, including whether fzf opens a floating pane. fzf#install() is + " also the vim-plug 'do' hook, so this can run long after the first fzf call + let [s:versions, s:checked] = [{}, {}] + unlet! s:exec s:tmux s:tmux_floating endfunction let s:versions = {} @@ -257,7 +268,7 @@ function! fzf#exec(...) endfunction " Path to the fzf-tmux script, or an empty string if it is not available. Only -" the legacy options still need it. --tmux is handled by fzf itself. +" the legacy options still need it. --popup is handled by fzf itself. function! s:fzf_tmux_script() if !executable(s:fzf_tmux) if !executable('fzf-tmux') @@ -273,14 +284,7 @@ function! s:tmux_enabled() return 0 endif - " --tmux covers Zellij as well, where the fzf-tmux script and the tmux - " version are irrelevant, but the binary only learned it in 0.71.0 - if exists('$ZELLIJ') - return exists('s:exec') - \ && s:compare_versions(s:get_version(s:exec), '0.71.0') >= 0 - endif - - if !exists('$TMUX') + if empty($TMUX) && empty($ZELLIJ) return 0 endif @@ -288,7 +292,21 @@ function! s:tmux_enabled() return s:tmux endif - let s:tmux = 0 + let [s:tmux, s:tmux_floating] = [0, 0] + + " --popup covers Zellij as well, where the fzf-tmux script and the tmux + " version are irrelevant. fzf learned it in 0.71.0, and the floating pane + " options it passes need Zellij 0.44 or above. fzf checks tmux first, so + " this branch is Zellij without tmux. Both non-empty means tmux wins. + " empty(), not exists(), to match how fzf reads the two variables + if empty($TMUX) + let s:tmux = + \ s:compare_versions(s:get_version(s:fzf_binary()), '0.71.0') >= 0 + \ && s:compare_versions(s:zellij_version(), '0.44') >= 0 + let s:tmux_floating = s:tmux + return s:tmux + endif + let output = system('tmux -V') if v:shell_error return s:tmux @@ -296,9 +314,15 @@ function! s:tmux_enabled() " e.g. 'tmux 3.7b', 'tmux next-3.8' let ver = matchstr(output, '\d\+\.\d\+') - " --tmux requires tmux 3.3 or above, and needs no fzf-tmux script + " --popup requires tmux 3.3 or above, and needs no fzf-tmux script. The + " default layout wants a floating pane, which also needs fzf 0.74.0 or + " above. fzf opens a modal popup otherwise. The version here only skips the + " probe for servers too old to answer it if s:compare_versions(ver, '3.3') >= 0 let s:tmux = 1 + let s:tmux_floating = s:compare_versions(ver, '3.7') >= 0 + \ && s:tmux_floating_pane_info() + \ && s:compare_versions(s:get_version(s:fzf_binary()), '0.74.0') >= 0 return s:tmux endif @@ -451,7 +475,10 @@ function! fzf#wrap(...) if !exists('g:fzf_layout') && exists('g:fzf_height') let opts.down = g:fzf_height else - let opts = extend(opts, s:validate_layout(get(g:, 'fzf_layout', s:default_layout()))) + " Not get(), which would evaluate s:default_layout() and run its version + " checks even when g:fzf_layout makes the answer irrelevant + let opts = extend(opts, s:validate_layout( + \ exists('g:fzf_layout') ? g:fzf_layout : s:default_layout())) endif endif @@ -627,6 +654,57 @@ function! s:present(dict, ...) return 0 endfunction +" The binary fzf#exec() would choose, without its prompting or installing. +" Layout selection runs before fzf#exec() has resolved one +function! s:fzf_binary() + if exists('s:exec') + return s:exec + endif + let bins = filter(['fzf', s:fzf_go], 'executable(v:val)') + if empty(bins) + return '' + endif + return len(bins) > 1 ? sort(bins, 's:compare_binary_versions')[-1] : bins[0] +endfunction + +function! s:zellij_version() + if !exists('s:zellij_ver') + let output = systemlist('zellij --version') + let s:zellij_ver = v:shell_error || empty(output) + \ ? '' : matchstr(output[0], '[0-9.]\+') + endif + return s:zellij_ver +endfunction + +" fzf decides on the running server, not on the version the tmux client +" reports, so ask it the same question rather than predicting the answer. +" See tmuxFloatingPaneInfo in src/tmux.go +function! s:tmux_floating_pane_info() + " fzf does not use a floating pane when it was not started from a pane + if empty($TMUX_PANE) + return 0 + endif + let out = system('tmux display-message -p -t '.fzf#shellescape($TMUX_PANE) + \ .' ''#{window_width} #{window_height}'' '';'' list-commands new-pane') + " A server that does not know the command exits normally with no output + if v:shell_error || out !~# 'new-pane' + return 0 + endif + " Too small for a floating pane of the minimum size + return str2nr(matchstr(out, '^\s*\zs\d\+')) >= 3 + \ && str2nr(matchstr(out, '^\s*\d\+\s\+\zs\d\+')) >= 3 +endfunction + +" Whether fzf will open a floating pane that can be left while fzf runs. A +" tmux popup below 3.7 is modal, and the fzf-tmux script that older tmux +" versions need does not accept a --popup value at all. Without a job to wait +" on fzf, s:execute_tmux() blocks on system() and the window stays visible but +" frozen, so the default keeps fzf inside Vim there +function! s:floating_pane() + return (has('nvim') || has('job')) + \ && s:tmux_enabled() && get(s:, 'tmux_floating', 0) +endfunction + function! s:fzf_tmux(dict) let size = get(a:dict, 'tmux', '') if empty(size) diff --git a/test/vim/fzf.vader b/test/vim/fzf.vader index 28115d0b..402a6d99 100644 --- a/test/vim/fzf.vader +++ b/test/vim/fzf.vader @@ -102,9 +102,36 @@ Execute (fzf#run with dir option and autochdir when final cwd is same as dir): " Working directory changed due to &acd AssertEqual '/', getcwd() +Execute (Default layout): + unlet! g:fzf_layout g:fzf_height + let layout_keys = ['window', 'popup', 'tmux', 'up', 'down', 'left', 'right'] + + let opts = fzf#wrap('foobar') + Log opts + let found = filter(copy(layout_keys), 'has_key(opts, v:val)') + AssertEqual 1, len(found) + if found[0] ==# 'tmux' + " Only where fzf opens a floating pane, which can be left while fzf runs + Assert !empty($TMUX) || !empty($ZELLIJ) + AssertEqual '90%,60%', opts.tmux + elseif found[0] ==# 'window' + AssertEqual 0.9, opts.window.width + else + " No popup support in this build + AssertEqual '~40%', opts.down + endif + + " Fullscreen strips it, whichever it was + let opts = fzf#wrap('foobar', {}, 1) + Log opts + AssertEqual [], filter(copy(layout_keys), 'has_key(opts, v:val)') + Execute (fzf#wrap): AssertThrows fzf#wrap({'foo': 'bar'}) + " Pin the layout so the assertions do not depend on the environment + let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } } + let opts = fzf#wrap('foobar') Log opts AssertEqual 0.9, opts.window.width