Set --border-label as the title of the tmux floating pane (#4853)

The pane sets the options itself before running fzf, so that they are
in place no matter how quickly the command exits, targeted at
$TMUX_PANE; the default target would resolve to the active pane of
the session's current window.

- pane-border-format is set to '#{pane_title}' so that the label is
  displayed on the border when pane-border-status is enabled;
  pane-border-status itself is a window option in released tmux
  versions and is left alone
- When a border style is explicitly specified with --border, a popup
  is used instead of a floating pane so that the fzf-drawn border is
  the only border shown; give 'border-native' to force a floating pane
- 'none' and 'line' are treated as no border; fzf draws no box for
  either, so the label is displayed on the native border
- Remove 'border-fzf' which is now redundant; it was never released
- The title is escaped for select-pane -T which expands format
  expressions; a lone ';' is escaped as tmux would treat it as a
  command separator
- The label is skipped when ANSI stripping leaves an empty string
- --border-label-pos is ignored
- Fix remain-on-exit set on the original pane instead of the floating
  pane
This commit is contained in:
Junegunn Choi
2026-07-05 19:35:34 +09:00
committed by GitHub
parent 1e31e5dfbe
commit 77e6394f50
8 changed files with 146 additions and 40 deletions
+6 -2
View File
@@ -6,9 +6,13 @@ CHANGELOG
- On tmux 3.7 or above, `--popup` starts fzf in a floating pane instead of a popup (#4850) - On tmux 3.7 or above, `--popup` starts fzf in a floating pane instead of a popup (#4850)
- Unlike a popup, a floating pane is not modal; you can switch to other panes and windows while fzf is running, move and resize the pane with the mouse, zoom it to fullscreen, and use copy-mode in it - Unlike a popup, a floating pane is not modal; you can switch to other panes and windows while fzf is running, move and resize the pane with the mouse, zoom it to fullscreen, and use copy-mode in it
- A floating pane always has a native border, which is what makes the pane movable and resizable, so `border-native` is implied - A floating pane always has a native border, which is what makes the pane movable and resizable, so `border-native` is implied
- Give `border-fzf` to fall back to a popup where fzf draws its own border - A popup is used instead when a border style is explicitly specified with `--border`, so that the fzf-drawn border is the only border shown (`none` and `line` are treated as no border)
```sh ```sh
fzf --popup center,80%,border-fzf fzf --popup --border rounded
```
- `--border-label` is set as the title of the floating pane, and is displayed on the border if `pane-border-status` is enabled in tmux
```sh
fzf --popup --border-label ' fzf '
``` ```
- Added `result-final` event, a variant of `result` that is not triggered while the input stream is still open (#4835) - Added `result-final` event, a variant of `result` that is not triggered while the input stream is still open (#4835)
- Use it for one-shot, per-query actions that would otherwise re-fire on every intermediate snapshot during loading - Use it for one-shot, per-query actions that would otherwise re-fire on every intermediate snapshot during loading
+10 -4
View File
@@ -417,7 +417,7 @@ layout options so that the specified number of items are visible in the list
section (default: \fB10+\fR). section (default: \fB10+\fR).
Ignored when \fB\-\-height\fR is not specified or set as an absolute value. Ignored when \fB\-\-height\fR is not specified or set as an absolute value.
.TP .TP
.BI "\-\-popup" "[=[center|top|bottom|left|right][,SIZE[%]][,SIZE[%]][,border-native|border-fzf]]" .BI "\-\-popup" "[=[center|top|bottom|left|right][,SIZE[%]][,SIZE[%]][,border-native]]"
Start fzf in a tmux or Zellij floating pane (default \fBcenter,50%\fR). Start fzf in a tmux or Zellij floating pane (default \fBcenter,50%\fR).
Requires tmux 3.3+ or Zellij 0.44+. This option is ignored if you 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. are not running fzf inside tmux or Zellij. \fB\-\-tmux\fR is an alias for this option.
@@ -426,9 +426,15 @@ On tmux 3.7 or above, the floating pane is not modal; you can switch to
other panes and windows while fzf is running, move and resize the pane with other panes and windows while fzf is running, move and resize the pane with
the mouse, zoom it to fullscreen, and use copy\-mode in it. The pane always the mouse, zoom it to fullscreen, and use copy\-mode in it. The pane always
has a native border, which is what makes it movable and resizable, so has a native border, which is what makes it movable and resizable, so
\fBborder\-native\fR is implied. On tmux versions below 3.7, or when \fBborder\-native\fR is implied. \fB\-\-border\-label\fR is set as the title
\fBborder\-fzf\fR is given, a modal tmux popup is used instead and fzf draws of the pane, and is displayed on the border if \fBpane\-border\-status\fR
its own border. is enabled in tmux (\fB\-\-border\-label\-pos\fR is ignored).
A modal tmux popup is used instead on tmux versions below 3.7, or when a
border style is explicitly specified with \fB\-\-border\fR, so that the
fzf\-drawn border is the only border shown. \fBnone\fR and \fBline\fR are
treated as no border. Give \fBborder\-native\fR to
force a floating pane nonetheless.
e.g. e.g.
\fB# Popup in the center with 70% width and height \fB# Popup in the center with 70% width and height
+6 -18
View File
@@ -77,7 +77,7 @@ Usage: fzf [options]
according to the other layout options (default: 10+). according to the other layout options (default: 10+).
--popup[=OPTS] Start fzf in a floating pane (requires tmux 3.3+ or Zellij 0.44+) --popup[=OPTS] Start fzf in a floating pane (requires tmux 3.3+ or Zellij 0.44+)
[center|top|bottom|left|right][,SIZE[%]][,SIZE[%]] [center|top|bottom|left|right][,SIZE[%]][,SIZE[%]]
[,border-native|border-fzf] (default: center,50%) [,border-native] (default: center,50%)
--tmux[=OPTS] Alias for --popup --tmux[=OPTS] Alias for --popup
LAYOUT LAYOUT
@@ -335,20 +335,12 @@ const (
posNext // adjacent to the input section, on the list side posNext // adjacent to the input section, on the list side
) )
type tmuxBorder int
const (
tmuxBorderAuto tmuxBorder = iota
tmuxBorderNative
tmuxBorderFzf
)
type tmuxOptions struct { type tmuxOptions struct {
width sizeSpec width sizeSpec
height sizeSpec height sizeSpec
position windowPosition position windowPosition
index int index int
border tmuxBorder border bool
} }
type layoutType int type layoutType int
@@ -435,19 +427,15 @@ func parseTmuxOptions(arg string, index int) (*tmuxOptions, error) {
var err error var err error
opts := defaultTmuxOptions(index) opts := defaultTmuxOptions(index)
tokens := splitRegexp.Split(arg, -1) tokens := splitRegexp.Split(arg, -1)
errorToReturn := errors.New("invalid popup option: " + arg + " (expected: [center|top|bottom|left|right][,SIZE[%]][,SIZE[%]][,border-native|border-fzf])") errorToReturn := errors.New("invalid popup option: " + arg + " (expected: [center|top|bottom|left|right][,SIZE[%]][,SIZE[%]][,border-native])")
if len(tokens) == 0 || len(tokens) > 4 { if len(tokens) == 0 || len(tokens) > 4 {
return nil, errorToReturn return nil, errorToReturn
} }
for i, token := range tokens { for i, token := range tokens {
if token == "border-native" || token == "border-fzf" { if token == "border-native" {
tokens = append(tokens[:i], tokens[i+1:]...) // cut the border option tokens = append(tokens[:i], tokens[i+1:]...) // cut the 'border-native' option
if token == "border-native" { opts.border = true
opts.border = tmuxBorderNative
} else {
opts.border = tmuxBorderFzf
}
break break
} }
} }
+2 -2
View File
@@ -26,14 +26,14 @@ func escapeSingleQuote(str string) string {
func popupArgStr(args []string, opts *Options) (string, string) { func popupArgStr(args []string, opts *Options) (string, string) {
fzf, rest := args[0], args[1:] fzf, rest := args[0], args[1:]
args = []string{"--bind=ctrl-z:ignore"} args = []string{"--bind=ctrl-z:ignore"}
if opts.Tmux.border != tmuxBorderNative && (opts.BorderShape == tui.BorderUndefined || opts.BorderShape == tui.BorderLine) { if !opts.Tmux.border && (opts.BorderShape == tui.BorderUndefined || opts.BorderShape == tui.BorderLine) {
if tui.DefaultBorderShape == tui.BorderRounded { if tui.DefaultBorderShape == tui.BorderRounded {
rest = append(rest, "--border=rounded") rest = append(rest, "--border=rounded")
} else { } else {
rest = append(rest, "--border=sharp") rest = append(rest, "--border=sharp")
} }
} }
if opts.Tmux.border == tmuxBorderNative && opts.Margin == defaultMargin() { if opts.Tmux.border && opts.Margin == defaultMargin() {
args = append(args, "--margin=0,1") args = append(args, "--margin=0,1")
} }
argStr := escapeSingleQuote(fzf) argStr := escapeSingleQuote(fzf)
+67 -11
View File
@@ -6,6 +6,8 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/junegunn/fzf/src/tui"
) )
// Returns the size of the current window if the tmux server supports // Returns the size of the current window if the tmux server supports
@@ -34,6 +36,21 @@ func tmuxFloatingPaneInfo() (int, int, bool) {
return width, height, true return width, height, true
} }
// A lone ';' argument is a command separator to tmux, aborting the whole
// command at parse time
func escapeTmuxSeparator(str string) string {
if str == ";" {
return `\;`
}
return str
}
// Escape a string for use as the pane title; select-pane -T expands format
// expressions denoted by '#', but not time conversion specifiers
func escapeTmuxTitle(str string) string {
return escapeTmuxSeparator(strings.ReplaceAll(str, "#", "##"))
}
// Convert sizeSpec to the number of cells, clamped between the minimum // Convert sizeSpec to the number of cells, clamped between the minimum
// footprint of 3, including the border, and the window size // footprint of 3, including the border, and the window size
func tmuxDim(spec sizeSpec, window int) int { func tmuxDim(spec sizeSpec, window int) int {
@@ -90,19 +107,53 @@ func runTmuxFloatingPane(argStr string, dir string, windowWidth int, windowHeigh
} }
f.Close() f.Close()
code := escapeSingleQuote(codeFile) code := escapeSingleQuote(codeFile)
paneCmd := fmt.Sprintf("%s %s; echo $? > %s; tmux wait-for -S %s",
escapeSingleQuote(sh), escapeSingleQuote(temp), code, signal) // Pane options are set by the pane itself before running fzf, so
// that they are in place no matter how quickly the command exits.
// The target must be explicit; without it, the commands would
// resolve to the active pane of the session's current window,
// which is not necessarily the pane running them.
//
// The pane should always close on exit like a popup, even when
// remain-on-exit is on.
setup := `tmux set-option -p -t "$TMUX_PANE" remain-on-exit off 2> /dev/null; `
// Set --border-label as the title of the floating pane, and as its
// pane-border-format so that it is displayed on the border when
// pane-border-status is enabled. pane-border-format is pane-scoped,
// but pane-border-status is a window option that only becomes
// pane-scoped in the next release of tmux, so it is left alone.
// https://github.com/tmux/tmux/commit/7a18fa281db3
// --border-label-pos is ignored.
// Skipped when fzf draws its own border with the label on it.
// '--border=none' is included; fzf would not display the label, but
// the native border of a floating pane cannot be removed, so
// display the label on it nonetheless.
if opts.BorderLabel.label != "" &&
(opts.BorderShape == tui.BorderUndefined || opts.BorderShape == tui.BorderLine ||
opts.BorderShape == tui.BorderNone) {
// Strip ANSI sequences fzf would otherwise render itself
label, _, _ := extractColor(opts.BorderLabel.label, nil, nil)
if label != "" {
setup += fmt.Sprintf(`tmux select-pane -t "$TMUX_PANE" -T %s 2> /dev/null; `,
escapeSingleQuote(escapeTmuxTitle(label)))
// The title is displayed verbatim; substituted values are
// not expanded again
setup += `tmux set-option -p -t "$TMUX_PANE" pane-border-format '#{pane_title}' 2> /dev/null; `
}
}
paneCmd := fmt.Sprintf("%s%s %s; echo $? > %s; tmux wait-for -S %s",
setup, escapeSingleQuote(sh), escapeSingleQuote(temp), code, signal)
// Unzoom the window first; creating a floating pane over a zoomed // Unzoom the window first; creating a floating pane over a zoomed
// window crashes the tmux server on 3.7b, and newer versions of // window crashes the tmux server on 3.7b, and newer versions of
// tmux unzoom the window anyway. The pane should always close on // tmux unzoom the window anyway.
// exit like a popup, even when remain-on-exit is on. set-option
// targets the new pane as new-pane makes it the current pane.
target := os.Getenv("TMUX_PANE") target := os.Getenv("TMUX_PANE")
newPane := fmt.Sprintf( newPane := fmt.Sprintf(
"tmux if -F -t %s '#{window_zoomed_flag}' %s ';' new-pane -P -F '#{pane_id}' -t %s -c %s -x %d -y %d -X %d -Y %d %s -c %s ';' set-option -p remain-on-exit off", "tmux if -F -t %s '#{window_zoomed_flag}' %s ';' new-pane -P -F '#{pane_id}' -t %s -c %s -x %d -y %d -X %d -Y %d %s -c %s",
escapeSingleQuote(target), escapeSingleQuote("resize-pane -Z -t "+target), escapeSingleQuote(target), escapeSingleQuote("resize-pane -Z -t "+target),
escapeSingleQuote(target), escapeSingleQuote(dir), width-2, height-2, x, y, escapeSingleQuote(target), escapeSingleQuote(dir), width-2, height-2, x, y,
escapeSingleQuote(sh), escapeSingleQuote(paneCmd)) escapeSingleQuote(sh), escapeSingleQuote(paneCmd))
// The pane is killed when the proxy process is interrupted or hung up, // The pane is killed when the proxy process is interrupted or hung up,
// like a popup dying with its client. wait-for runs in the background // like a popup dying with its client. wait-for runs in the background
// and is awaited with the interruptible wait builtin so that the trap // and is awaited with the interruptible wait builtin so that the trap
@@ -130,11 +181,16 @@ exit "$code"`, newPane, code, signal, signal, code, code, code)
func runTmux(args []string, opts *Options) (int, error) { func runTmux(args []string, opts *Options) (int, error) {
// On tmux 3.7 or above, fzf runs in a floating pane instead of a popup. // On tmux 3.7 or above, fzf runs in a floating pane instead of a popup.
// A floating pane always has a native border, so 'border-native' is // A floating pane always has a native border, so 'border-native' is
// implied. Give 'border-fzf' to fall back to a popup where fzf draws // implied. When a border style is explicitly specified with --border, a
// its own border. // popup is used instead so that the fzf-drawn border is the only border
if opts.Tmux.border != tmuxBorderFzf { // shown; the native border of a floating pane cannot be removed. 'none'
// and 'line' are treated as no border; fzf draws no box for either, and
// 'line' only makes sense with --height. Give 'border-native' to force
// a floating pane nonetheless.
if opts.Tmux.border || opts.BorderShape == tui.BorderUndefined ||
opts.BorderShape == tui.BorderLine || opts.BorderShape == tui.BorderNone {
if windowWidth, windowHeight, ok := tmuxFloatingPaneInfo(); ok { if windowWidth, windowHeight, ok := tmuxFloatingPaneInfo(); ok {
opts.Tmux.border = tmuxBorderNative opts.Tmux.border = true
argStr, dir := popupArgStr(args, opts) argStr, dir := popupArgStr(args, opts)
return runTmuxFloatingPane(argStr, dir, windowWidth, windowHeight, opts) return runTmuxFloatingPane(argStr, dir, windowWidth, windowHeight, opts)
} }
@@ -150,7 +206,7 @@ func runTmux(args []string, opts *Options) (int, error) {
// W Both The window position on the status line // W Both The window position on the status line
// S -y The line above or below the status line // S -y The line above or below the status line
tmuxArgs := []string{"display-popup", "-E", "-d", dir} tmuxArgs := []string{"display-popup", "-E", "-d", dir}
if opts.Tmux.border != tmuxBorderNative { if !opts.Tmux.border {
tmuxArgs = append(tmuxArgs, "-B") tmuxArgs = append(tmuxArgs, "-B")
} }
switch opts.Tmux.position { switch opts.Tmux.position {
+39
View File
@@ -0,0 +1,39 @@
package fzf
import "testing"
func TestEscapeTmuxTitle(t *testing.T) {
for _, tc := range []struct {
given string
expected string
}{
{"", ""},
{" fzf ", " fzf "},
{"#", "##"},
{"##", "####"},
{" C# notes #S ", " C## notes ##S "},
{"100%", "100%"},
{";", `\;`},
{"; rm", "; rm"},
{" ; ", " ; "},
} {
if actual := escapeTmuxTitle(tc.given); actual != tc.expected {
t.Errorf("expected %q, got %q", tc.expected, actual)
}
}
}
func TestEscapeTmuxTitleSeparator(t *testing.T) {
for _, tc := range []struct {
given string
expected string
}{
{"#;", "##;"},
{";#", ";##"},
{";;", ";;"},
} {
if actual := escapeTmuxTitle(tc.given); actual != tc.expected {
t.Errorf("expected %q, got %q", tc.expected, actual)
}
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ func runZellij(args []string, opts *Options) (int, error) {
"run", "--floating", "--close-on-exit", "--block-until-exit", "run", "--floating", "--close-on-exit", "--block-until-exit",
"--cwd", dir, "--cwd", dir,
} }
if opts.Tmux.border != tmuxBorderNative { if !opts.Tmux.border {
zellijArgs = append(zellijArgs, "--borderless", "true") zellijArgs = append(zellijArgs, "--borderless", "true")
} }
switch opts.Tmux.position { switch opts.Tmux.position {
+15 -2
View File
@@ -32,7 +32,20 @@ class TestTmux < TestInteractive
tmux.until { |lines| assert lines.any_include?('code:130') } tmux.until { |lines| assert lines.any_include?('code:130') }
end end
def test_border_fzf_falls_back_to_popup def test_floating_pane_border_label
tmux.send_keys "seq 100 | #{fzf(%(--popup center,80% --margin 0 --border-label ' #fzf-label 100% '))}", :Enter
tmux.until { |lines| assert_equal 100, lines.item_count }
pane = floating_pane
refute_nil pane
title = IO.popen(['tmux', 'display-message', '-p', '-t', pane, "\#{pane_title}"], &:read)
assert_equal ' #fzf-label 100% ', title.chomp
format = IO.popen(['tmux', 'show-options', '-p', '-t', pane, 'pane-border-format'], &:read)
assert_includes format, "\#{pane_title}"
tmux.send_keys :Enter
assert_equal '1', fzf_output
end
def test_explicit_border_falls_back_to_popup
# display-popup requires an attached client, which the test environment # display-popup requires an attached client, which the test environment
# may not have; intercept it with a tmux shim on PATH # may not have; intercept it with a tmux shim on PATH
dir = Dir.mktmpdir dir = Dir.mktmpdir
@@ -47,7 +60,7 @@ class TestTmux < TestInteractive
exec #{real.shellescape} "$@" exec #{real.shellescape} "$@"
SH SH
FileUtils.chmod(0o755, shim) FileUtils.chmod(0o755, shim)
tmux.send_keys "seq 100 | PATH=#{dir.shellescape}:$PATH #{FZF} --popup center,border-fzf", :Enter tmux.send_keys "seq 100 | PATH=#{dir.shellescape}:$PATH #{FZF} --popup center --border rounded", :Enter
tmux.until { |lines| assert lines.any_include?('popup-used') } tmux.until { |lines| assert lines.any_include?('popup-used') }
refute floating_pane refute floating_pane
ensure ensure