diff --git a/CHANGELOG.md b/CHANGELOG.md index 5db41b61..e33d4706 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,13 @@ CHANGELOG - 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 - 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 - 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) - Use it for one-shot, per-query actions that would otherwise re-fire on every intermediate snapshot during loading diff --git a/man/man1/fzf.1 b/man/man1/fzf.1 index 226c8629..17f04fd5 100644 --- a/man/man1/fzf.1 +++ b/man/man1/fzf.1 @@ -417,7 +417,7 @@ 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 "\-\-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). 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. @@ -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 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 -\fBborder\-native\fR is implied. On tmux versions below 3.7, or when -\fBborder\-fzf\fR is given, a modal tmux popup is used instead and fzf draws -its own border. +\fBborder\-native\fR is implied. \fB\-\-border\-label\fR is set as the title +of the pane, and is displayed on the border if \fBpane\-border\-status\fR +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. \fB# Popup in the center with 70% width and height diff --git a/src/options.go b/src/options.go index 955d05b2..5322f3da 100644 --- a/src/options.go +++ b/src/options.go @@ -77,7 +77,7 @@ Usage: fzf [options] according to the other layout options (default: 10+). --popup[=OPTS] Start fzf in a floating pane (requires tmux 3.3+ or Zellij 0.44+) [center|top|bottom|left|right][,SIZE[%]][,SIZE[%]] - [,border-native|border-fzf] (default: center,50%) + [,border-native] (default: center,50%) --tmux[=OPTS] Alias for --popup LAYOUT @@ -335,20 +335,12 @@ const ( posNext // adjacent to the input section, on the list side ) -type tmuxBorder int - -const ( - tmuxBorderAuto tmuxBorder = iota - tmuxBorderNative - tmuxBorderFzf -) - type tmuxOptions struct { width sizeSpec height sizeSpec position windowPosition index int - border tmuxBorder + border bool } type layoutType int @@ -435,19 +427,15 @@ func parseTmuxOptions(arg string, index int) (*tmuxOptions, error) { var err error opts := defaultTmuxOptions(index) 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 { return nil, errorToReturn } for i, token := range tokens { - if token == "border-native" || token == "border-fzf" { - tokens = append(tokens[:i], tokens[i+1:]...) // cut the border option - if token == "border-native" { - opts.border = tmuxBorderNative - } else { - opts.border = tmuxBorderFzf - } + if token == "border-native" { + tokens = append(tokens[:i], tokens[i+1:]...) // cut the 'border-native' option + opts.border = true break } } diff --git a/src/proxy.go b/src/proxy.go index cbf2ac47..c02aedcf 100644 --- a/src/proxy.go +++ b/src/proxy.go @@ -26,14 +26,14 @@ func escapeSingleQuote(str string) string { func popupArgStr(args []string, opts *Options) (string, string) { fzf, rest := args[0], args[1:] 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 { rest = append(rest, "--border=rounded") } else { 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") } argStr := escapeSingleQuote(fzf) diff --git a/src/tmux.go b/src/tmux.go index c4834532..a0c0f497 100644 --- a/src/tmux.go +++ b/src/tmux.go @@ -6,6 +6,8 @@ import ( "os/exec" "path/filepath" "strings" + + "github.com/junegunn/fzf/src/tui" ) // 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 } +// 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 // footprint of 3, including the border, and the window size func tmuxDim(spec sizeSpec, window int) int { @@ -90,19 +107,53 @@ func runTmuxFloatingPane(argStr string, dir string, windowWidth int, windowHeigh } f.Close() 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 // window crashes the tmux server on 3.7b, and newer versions of - // tmux unzoom the window anyway. The pane should always close on - // 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. + // tmux unzoom the window anyway. target := os.Getenv("TMUX_PANE") 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(dir), width-2, height-2, x, y, escapeSingleQuote(sh), escapeSingleQuote(paneCmd)) + // 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 // 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) { // 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 - // implied. Give 'border-fzf' to fall back to a popup where fzf draws - // its own border. - if opts.Tmux.border != tmuxBorderFzf { + // implied. When a border style is explicitly specified with --border, a + // popup is used instead so that the fzf-drawn border is the only border + // 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 { - opts.Tmux.border = tmuxBorderNative + opts.Tmux.border = true argStr, dir := popupArgStr(args, 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 // S -y The line above or below the status line tmuxArgs := []string{"display-popup", "-E", "-d", dir} - if opts.Tmux.border != tmuxBorderNative { + if !opts.Tmux.border { tmuxArgs = append(tmuxArgs, "-B") } switch opts.Tmux.position { diff --git a/src/tmux_test.go b/src/tmux_test.go new file mode 100644 index 00000000..3e9da617 --- /dev/null +++ b/src/tmux_test.go @@ -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) + } + } +} diff --git a/src/zellij.go b/src/zellij.go index 0fb6532d..dce9dd40 100644 --- a/src/zellij.go +++ b/src/zellij.go @@ -11,7 +11,7 @@ func runZellij(args []string, opts *Options) (int, error) { "run", "--floating", "--close-on-exit", "--block-until-exit", "--cwd", dir, } - if opts.Tmux.border != tmuxBorderNative { + if !opts.Tmux.border { zellijArgs = append(zellijArgs, "--borderless", "true") } switch opts.Tmux.position { diff --git a/test/test_tmux.rb b/test/test_tmux.rb index 5e0888c6..71ed319b 100644 --- a/test/test_tmux.rb +++ b/test/test_tmux.rb @@ -32,7 +32,20 @@ class TestTmux < TestInteractive tmux.until { |lines| assert lines.any_include?('code:130') } 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 # may not have; intercept it with a tmux shim on PATH dir = Dir.mktmpdir @@ -47,7 +60,7 @@ class TestTmux < TestInteractive exec #{real.shellescape} "$@" SH 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') } refute floating_pane ensure