Compare commits

..

13 Commits

Author SHA1 Message Date
Junegunn Choi 14755d683a Hide separator next to preview window at 'next' position when decidable
Decision must not depend on threshold chain resolution
(computePreviewSize consults noSeparatorLine); separator is hidden only
when every spec in the chain displays a 'next' preview window with a
border line facing the input section. Otherwise err on the side of
showing it.

- Without active previewer, preview position is resolved from first spec
  alone, so 'next' in a threshold alternative does not create input
  window and cannot hide the separator
- Empty preview command is undecidable; preview(...) action can display
  ad-hoc window at 'next' position at any time
- change-preview-window relayouts or repaints when separator visibility
  changes even if the active spec is unchanged, without dropping preview
  window forced by preview(...) action
2026-07-18 13:32:11 +09:00
Junegunn Choi 9e2006306a Apply facing-line rule to input border as well
Visible input border suppressed separator even when it draws no line
toward list section (bottom in default layout, top in reverse, vertical).
Suppress only when input border has a line on the list side; otherwise
fall through to adjacent section check.

- Requires header window to have content before treating it as facing
  section; hasHeaderWindow returns true with empty header when input
  border is visible
- Extract inputBorderFacesList and resolveInputBorderShape shared by
  options and terminal
- Add tests for separator suppression rules
2026-07-18 13:21:28 +09:00
Junegunn Choi c60bcf67a5 Hide default separator when a border separates input from list
- Applies when adjacent section (header, header lines, or list) draws
  border line facing input section

    # No separator below header border
    fzf --style full --input-border none --header foo

    # Separator shown; nothing separates input from list
    fzf --style full --input-border none --header foo --no-header-border

- Evaluated dynamically so change-header/toggle-header update it
- Inline header is drawn inside list border and not adjacent to input;
  decide from section actually next to it
- Separator is kept when a preview window can be at 'next' position,
  right next to the input section
2026-07-18 10:51:49 +09:00
Junegunn Choi e877b6f846 Update CHANGELOG 2026-07-17 14:44:20 +09:00
Junegunn Choi b9cbe3a6b8 Publish .deb package for each release
Close #4859
2026-07-17 14:44:20 +09:00
Junegunn Choi 7e134f0a78 Update CHANGELOG 2026-07-17 10:47:56 +09:00
Junegunn Choi 3c0de84dab Skip redundant SGR sequences in light renderer
Track the last emitted SGR sequence and skip no-op transitions.
Reduces rendering output by 10-23% depending on the scenario.
2026-07-17 10:34:42 +09:00
Junegunn Choi 374b01242d Wrap frames in synchronized update mode (2026)
Reduce flickering during rendering on supported terminals.
2026-07-16 23:20:29 +09:00
Junegunn Choi c0860259fa Use DECSC/DECRC instead of CSI s/u
Honored by more terminals, e.g. fixes cursor restoration with
--height --no-clear inside nvim terminal.
2026-07-16 23:00:39 +09:00
Junegunn Choi 1462f1358d Comment cleanup 2026-07-15 18:30:17 +09:00
Junegunn Choi 8e340dabbc Use CHA for absolute horizontal cursor movement
Related:
- #4858
- https://github.com/zellij-org/zellij/issues/5370
2026-07-15 18:23:08 +09:00
Junegunn Choi 4dcedc01df Comment why floating pane requires TMUX_PANE
Blocking run-shell suspends client key processing until the command
exits, so a floating pane deadlocks; popup input bypasses the suspended
path. Fall back to display-popup when TMUX_PANE is not set.
2026-07-15 17:45:08 +09:00
Simon Désaulniers 24832e97ef nushell: fix 0.114.0 nushell str downcase deprecated error
We switch to `str lowercase` and add a note for the Nushell<0.114.0 user
that should display at runtime.

Close #4857
2026-07-10 13:49:47 +09:00
12 changed files with 636 additions and 80 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
go-version: "1.23" go-version: "1.23"
- name: Setup Ruby - name: Setup Ruby
uses: ruby/setup-ruby@d45b1a4e94b71acab930e56e79c6aa188764e7f9 # v1 uses: ruby/setup-ruby@9eb537ca036ebaed86729dcb9309076e4c5c3b74 # v1
with: with:
ruby-version: 3.4.6 ruby-version: 3.4.6
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
go-version: "1.23" go-version: "1.23"
- name: Setup Ruby - name: Setup Ruby
uses: ruby/setup-ruby@d45b1a4e94b71acab930e56e79c6aa188764e7f9 # v1 uses: ruby/setup-ruby@9eb537ca036ebaed86729dcb9309076e4c5c3b74 # v1
with: with:
ruby-version: 3.0.0 ruby-version: 3.0.0
+19
View File
@@ -96,6 +96,25 @@ archives:
files: files:
- non-existent* - non-existent*
nfpms:
- package_name: fzf
file_name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
vendor: junegunn
homepage: https://github.com/junegunn/fzf
maintainer: Junegunn Choi <junegunn.c@gmail.com>
description: Command-line fuzzy finder
license: MIT
section: utils
formats:
- deb
ids:
- fzf
contents:
- src: man/man1/fzf.1
dst: /usr/share/man/man1/fzf.1
- src: LICENSE
dst: /usr/share/doc/fzf/copyright
release: release:
github: github:
owner: junegunn owner: junegunn
+25
View File
@@ -1,6 +1,31 @@
CHANGELOG CHANGELOG
========= =========
0.74.1
------
- The default separator on the info line is no longer shown when the input section is already visually separated from the list section by a border line
```sh
# No separator shown below the header border
fzf --style full --input-border none --header foo
# Separator shown; no border separates the input section from the list section
fzf --style full --input-border none --header foo --no-header-border
# No separator below the border of the preview window at 'next' position
fzf --preview : --preview-window next
# Conversely, separator is now shown when the input border does not draw
# a line facing the list section
fzf --input-border bottom
```
- Rendering improvements
- Each frame is now wrapped in synchronized update mode (mode 2026) to reduce flickering on supported terminals
- Reduced rendering output by 10-23% by skipping redundant SGR sequences
- Fixed ghost characters and misplaced colors inside Zellij by using CHA instead of CR + CUF for horizontal cursor movement (#4858, zellij-org/zellij#5370)
- Fixed cursor restoration on exit with `--height --no-clear` inside Neovim terminal by using DECSC/DECRC instead of `CSI s`/`CSI u`
- nushell: fixed deprecation error of `str downcase` on nushell 0.114.0 or above (#4857) (@sim590)
- Each release now includes `.deb` packages for easy installation on Debian-based distros (#4859)
0.74.0 0.74.0
------ ------
_Release highlights: https://junegunn.github.io/fzf/releases/0.74.0/_ _Release highlights: https://junegunn.github.io/fzf/releases/0.74.0/_
+4
View File
@@ -823,6 +823,10 @@ A synonym for \fB\-\-info=hidden\fB
The given string will be repeated to form the horizontal separator on the info The given string will be repeated to form the horizontal separator on the info
line (default: '─' or '\-' depending on \fB\-\-no\-unicode\fR). line (default: '─' or '\-' depending on \fB\-\-no\-unicode\fR).
Unless explicitly specified, the separator is not displayed if the input
section is already visually separated from the list section by a border line
(e.g. \fB\-\-input\-border\fR or \fB\-\-header\-border\fR).
ANSI color codes are supported. ANSI color codes are supported.
.TP .TP
+2 -1
View File
@@ -116,7 +116,8 @@ def __fzf_list_hosts [] {
( (
# Process ssh config files # Process ssh config files
$ssh_configs | append $ssh_configs_d | append $ssh_config_global $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') } # NOTE: str lowercase is new from Nushell 0.114.0. Please upgrade to Nushell >=0.114.0 if you are seeing `extra positional argument` error regarding `str lowercase` in this block.
| where {|it| ($it | str lowercase | str starts-with 'host') or ($it | str lowercase | str starts-with 'hostname') }
| parse --regex '^\s*host(?:name)?\s+(?<hosts>.+)' # Extract hosts after keyword | parse --regex '^\s*host(?:name)?\s+(?<hosts>.+)' # Extract hosts after keyword
| default { hosts: null } # Handle lines that don't match regex | default { hosts: null } # Handle lines that don't match regex
| get hosts | get hosts
+4 -1
View File
@@ -3696,7 +3696,10 @@ func (opts *Options) noSeparatorLine() bool {
if opts.Inputless { if opts.Inputless {
return true return true
} }
sep := opts.Separator == nil && !opts.InputBorderShape.Visible() || opts.Separator != nil && len(*opts.Separator) > 0 // NOTE: This does not know that the default separator can be suppressed at
// runtime (see Terminal.separatorLength), so the minimum heights derived
// from it can be one line taller than strictly necessary.
sep := opts.Separator == nil && !inputBorderFacesList(opts.Layout, opts.InputBorderShape) || opts.Separator != nil && len(*opts.Separator) > 0
return noSeparatorLine(opts.InfoStyle, sep) return noSeparatorLine(opts.InfoStyle, sep)
} }
+165 -22
View File
@@ -271,6 +271,7 @@ type Terminal struct {
ghost string ghost string
separator labelPrinter separator labelPrinter
separatorLen int separatorLen int
separatorAuto bool
spinner []string spinner []string
promptString string promptString string
prompt func() prompt func()
@@ -1248,13 +1249,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
} }
// Determine input border shape // Determine input border shape
if t.inputBorderShape == tui.BorderLine { t.inputBorderShape = resolveInputBorderShape(t.layout, t.inputBorderShape)
if t.layout == layoutReverse {
t.inputBorderShape = tui.BorderBottom
} else {
t.inputBorderShape = tui.BorderTop
}
}
// Inline borders are embedded between the list's top and bottom horizontals. // Inline borders are embedded between the list's top and bottom horizontals.
// Shapes missing either one (none/phantom/line/single-sided) fall back to a plain // Shapes missing either one (none/phantom/line/single-sided) fall back to a plain
@@ -1299,14 +1294,16 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
} }
} }
// Disable separator by default if input border is set // The default separator can be suppressed at runtime depending on the
if opts.Separator == nil && !t.inputBorderShape.Visible() || opts.Separator != nil && len(*opts.Separator) > 0 { // window layout (see separatorLength)
bar := "─" t.separatorAuto = opts.Separator == nil
if opts.Separator != nil { bar := "─"
bar = *opts.Separator if opts.Separator != nil {
} else if !t.unicode { bar = *opts.Separator
bar = "-" } else if !t.unicode {
} bar = "-"
}
if len(bar) > 0 {
t.separator, t.separatorLen = t.ansiLabelPrinter(bar, &tui.ColSeparator, true) t.separator, t.separatorLen = t.ansiLabelPrinter(bar, &tui.ColSeparator, true)
} }
@@ -1695,7 +1692,129 @@ func (t *Terminal) parsePrompt(prompt string) (func(), int) {
} }
func (t *Terminal) noSeparatorLine() bool { func (t *Terminal) noSeparatorLine() bool {
return t.inputless || noSeparatorLine(t.infoStyle, t.separatorLen > 0) return t.inputless || noSeparatorLine(t.infoStyle, t.separatorLength() > 0)
}
// Effective length of the horizontal separator. The default separator is
// suppressed when the input section is already visually separated from the
// list section by a border line.
func (t *Terminal) separatorLength() int {
if t.separatorAuto && t.separatedByBorder() {
return 0
}
return t.separatorLen
}
// BorderLine for the input border resolves to a single line on the side
// facing the list section
func resolveInputBorderShape(layout layoutType, shape tui.BorderShape) tui.BorderShape {
if shape != tui.BorderLine {
return shape
}
if layout == layoutReverse {
return tui.BorderBottom
}
return tui.BorderTop
}
// Whether the input border draws a line on the side facing the list section
func inputBorderFacesList(layout layoutType, shape tui.BorderShape) bool {
shape = resolveInputBorderShape(layout, shape)
if layout == layoutReverse {
return shape.HasBottom()
}
return shape.HasTop()
}
// Whether a border line is already drawn between the info line and the
// section next to it on the list side, making the default horizontal
// separator redundant
func (t *Terminal) separatedByBorder() bool {
// The input border itself draws a line on the side facing the list section
if inputBorderFacesList(t.layout, t.inputBorderShape) {
return true
}
// A preview window at 'next' position sits right next to the input
// section, in front of the sections handled below
anyNext := false
for po := &t.previewOpts; po != nil; po = po.alternative {
if po.position == posNext {
anyNext = true
break
}
}
if anyNext && t.hasPreviewer() {
// The separator is hidden only when every spec in the threshold chain
// displays a preview window at 'next' position with a border line
// facing the input section. We cannot single out the active spec;
// which one is active depends on the terminal size, and the
// resolution (computePreviewSize in resizeWindows) itself consults
// noSeparatorLine. When not every spec qualifies, err on the side of
// showing the separator.
if len(t.previewOpts.command) == 0 {
// No preview command, so no preview window is normally shown, but
// the preview(...) action can still display one at any time
return false
}
// NOTE: resizeWindows can clear 'hidden' mid-layout when the
// preview(...) action forces the window, so the frame being laid out
// can briefly disagree with this decision until the next relayout
for po := &t.previewOpts; po != nil; po = po.alternative {
if po.position != posNext || po.hidden || po.size.size == 0 || !t.borderFacingInput(po.Border(t.layout)) {
return false
}
}
return true
}
hasHeaderLinesWindow, headerLinesShape := t.determineHeaderLinesShape()
hasHeaderWindow := t.hasHeaderWindow()
// Mirror of hasInputWindow in resizeWindows; a 'next' preview position
// gives the input section its own window even when no preview window is
// shown. A 'next' spec here implies there is no previewer (the block
// above returns otherwise), and without a previewer the position is
// resolved from the first spec alone, so a 'next' in a threshold
// alternative does not count.
hasInputWindow := t.previewOpts.position == posNext || t.inputBorderShape.Visible() || hasHeaderWindow || hasHeaderLinesWindow
if !hasInputWindow {
// The input section is embedded in the list window and no border
// separates the two
return false
}
// The input section has its own window. Determine which section it is
// facing, and check if the border of that section draws a line toward it.
// NOTE: hasHeaderWindow can be true with no header content when the input
// border is visible (see Terminal.hasHeaderWindow). An empty header window
// takes no space, so it cannot be the facing section.
hasHeaderSection := hasHeaderWindow && t.visibleHeaderLines() > 0
if hasHeaderSection && !t.headerFirst && t.headerBorderShape != tui.BorderInline {
// Header section is facing the input section. An inline header is
// excluded; it is drawn inside the list border and is covered by the
// branches below.
return t.borderFacingInput(t.headerBorderShape)
}
// Header lines section is facing the input section, except in
// reverse-list layout where it is on the other side of the list section,
// or with --header-first and no header section where it is moved past
// the input section
if hasHeaderLinesWindow && t.layout != layoutReverseList && (hasHeaderWindow || !t.headerFirst) {
return t.borderFacingInput(headerLinesShape)
}
// The input section sits right next to the list section
return t.borderFacingInput(t.listBorderShape)
}
// Whether the border of the section next to the input section on the list
// side draws a horizontal line facing the info line
func (t *Terminal) borderFacingInput(shape tui.BorderShape) bool {
if shape == tui.BorderInline {
// Embedded between the horizontal lines of the list border
return true
}
if t.layout == layoutReverse {
return shape.HasTop()
}
return shape.HasBottom()
} }
func getScrollbar(perLine int, total int, height int, offset int) (int, int) { func getScrollbar(perLine int, total int, height int, offset int) (int, int) {
@@ -3330,6 +3449,7 @@ func (t *Terminal) printInfoImpl() {
} }
pos := 0 pos := 0
line := 0 line := 0
separatorLen := t.separatorLength()
maxHeight := t.window.Height() maxHeight := t.window.Height()
move := func(y int, x int, clear bool) bool { move := func(y int, x int, clear bool) bool {
if y < 0 || y >= maxHeight { if y < 0 || y >= maxHeight {
@@ -3357,7 +3477,7 @@ func (t *Terminal) printInfoImpl() {
str = string(trimmed) str = string(trimmed)
width = maxWidth width = maxWidth
} }
move(line, pos, t.separatorLen == 0) move(line, pos, separatorLen == 0)
if t.reading { if t.reading {
t.window.CPrint(tui.ColSpinner, str) t.window.CPrint(tui.ColSpinner, str)
} else { } else {
@@ -3366,7 +3486,7 @@ func (t *Terminal) printInfoImpl() {
pos += width pos += width
} }
printSeparator := func(fillLength int, pad bool) { printSeparator := func(fillLength int, pad bool) {
if t.separatorLen > 0 { if separatorLen > 0 {
t.separator(t.window, fillLength) t.separator(t.window, fillLength)
t.window.Print(" ") t.window.Print(" ")
} else if pad { } else if pad {
@@ -3375,7 +3495,7 @@ func (t *Terminal) printInfoImpl() {
} }
if t.infoStyle == infoHidden { if t.infoStyle == infoHidden {
if t.separatorLen > 0 { if separatorLen > 0 {
if !move(line+1, 0, false) { if !move(line+1, 0, false) {
return return
} }
@@ -3438,7 +3558,7 @@ func (t *Terminal) printInfoImpl() {
} }
switch t.infoStyle { switch t.infoStyle {
case infoDefault: case infoDefault:
if !move(line+1, 0, t.separatorLen == 0) { if !move(line+1, 0, separatorLen == 0) {
return return
} }
printSpinner() printSpinner()
@@ -3522,7 +3642,7 @@ func (t *Terminal) printInfoImpl() {
} }
if t.infoStyle == infoInlineRight { if t.infoStyle == infoInlineRight {
if t.separatorLen > 0 { if separatorLen > 0 {
if !move(line+1, 0, false) { if !move(line+1, 0, false) {
return return
} }
@@ -8157,6 +8277,8 @@ func (t *Terminal) Loop() error {
case actChangePreviewWindow: case actChangePreviewWindow:
// NOTE: We intentionally use "previewOpts" instead of "activePreviewOpts" here // NOTE: We intentionally use "previewOpts" instead of "activePreviewOpts" here
currentPreviewOpts := t.previewOpts currentPreviewOpts := t.previewOpts
wasNoSeparatorLine := t.noSeparatorLine()
wasSeparatorLength := t.separatorLength()
// Reset preview options and apply the additional options // Reset preview options and apply the additional options
t.previewOpts = t.initialPreviewOpts t.previewOpts = t.initialPreviewOpts
@@ -8177,8 +8299,28 @@ func (t *Terminal) Loop() error {
a.a = strings.Join(append(tokens[1:], tokens[0]), "|") a.a = strings.Join(append(tokens[1:], tokens[0]), "|")
} }
// Do not drop a preview window forced by the preview(...) action
keepForcedPreview := t.hasPreviewWindow() && !t.activePreviewOpts.hidden
// The default separator is decided from the whole threshold
// chain (separatedByBorder), which compare does not fully
// inspect, so it can change even when compare finds no
// layout difference
checkSeparator := func() {
if t.noSeparatorLine() != wasNoSeparatorLine {
// Number of lines changed; relayout
updatePreviewWindow(keepForcedPreview)
req(reqPreviewRefresh)
} else if t.separatorLength() != wasSeparatorLength {
// Only the content of the info line changed
req(reqInfo)
}
}
// Full redraw // Full redraw
switch currentPreviewOpts.compare(t.activePreviewOpts, &t.previewOpts) { switch currentPreviewOpts.compare(t.activePreviewOpts, &t.previewOpts) {
case previewOptsSame:
checkSeparator()
case previewOptsDifferentLayout: case previewOptsDifferentLayout:
// Preview command can be running in the background if the size of // Preview command can be running in the background if the size of
// the preview window is 0 but not 'hidden' // the preview window is 0 but not 'hidden'
@@ -8186,7 +8328,7 @@ func (t *Terminal) Loop() error {
// FIXME: One-time preview window can't reappear once hidden // FIXME: One-time preview window can't reappear once hidden
// fzf --bind space:preview:ls --bind 'enter:change-preview-window:down|left|up|hidden|' // fzf --bind space:preview:ls --bind 'enter:change-preview-window:down|left|up|hidden|'
updatePreviewWindow(t.hasPreviewWindow() && !t.activePreviewOpts.hidden) updatePreviewWindow(keepForcedPreview)
if wasHidden && t.hasPreviewWindow() { if wasHidden && t.hasPreviewWindow() {
// Restart // Restart
refreshPreview(t.previewOpts.command) refreshPreview(t.previewOpts.command)
@@ -8200,6 +8342,7 @@ func (t *Terminal) Loop() error {
case previewOptsDifferentContentLayout: case previewOptsDifferentContentLayout:
t.previewed.version = 0 t.previewed.version = 0
req(reqPreviewRefresh) req(reqPreviewRefresh)
checkSeparator()
} }
// Adjust scroll offset // Adjust scroll offset
+10
View File
@@ -13,6 +13,16 @@ import (
// Returns the size of the current window if the tmux server supports // Returns the size of the current window if the tmux server supports
// floating panes (tmux 3.7 or above) // floating panes (tmux 3.7 or above)
func tmuxFloatingPaneInfo() (int, int, bool) { func tmuxFloatingPaneInfo() (int, int, bool) {
// TMUX_PANE is not set when fzf is not started from within a pane
// (e.g. 'bind-key 0 run-shell "fzf --popup"'). Do not use a floating
// pane there; a blocking run-shell suspends key processing for the
// client until the command exits, so fzf in a floating pane would
// never receive a key and the client would appear frozen until the
// process is killed externally. A popup is unaffected; its input is
// handled at the client overlay level, bypassing the suspended path.
// The right approach for such bindings is 'run-shell -b', which does
// not suspend key processing, as discussed in:
// https://github.com/tmux/tmux/issues/5384
target := os.Getenv("TMUX_PANE") target := os.Getenv("TMUX_PANE")
if target == "" { if target == "" {
return 0, 0, false return 0, 0, false
+57 -33
View File
@@ -39,6 +39,7 @@ func (r *LightRenderer) Bell() {
func (r *LightRenderer) PassThrough(str string) { func (r *LightRenderer) PassThrough(str string) {
r.queued.WriteString("\x1b7" + str + "\x1b8") r.queued.WriteString("\x1b7" + str + "\x1b8")
r.invalidateSGR()
} }
func (r *LightRenderer) stderr(str string) { func (r *LightRenderer) stderr(str string) {
@@ -90,14 +91,36 @@ func (r *LightRenderer) csi(code string) string {
return fullcode return fullcode
} }
// setSGR emits the given SGR sequence only when the terminal is not already
// in that state. Sequences built by csiColor start with a reset parameter,
// so each of them fully determines the state on its own. The zero value of
// r.sgr never matches a sequence, so the first update after Init, Resume, or
// invalidateSGR is always emitted.
func (r *LightRenderer) setSGR(code string) {
if code != r.sgr {
r.stderr(code)
r.sgr = code
}
}
// invalidateSGR marks the terminal SGR state as unknown, e.g. after raw
// output that may have changed it behind the renderer's back.
func (r *LightRenderer) invalidateSGR() {
r.sgr = ""
}
func (r *LightRenderer) flush() { func (r *LightRenderer) flush() {
if r.queued.Len() > 0 { if r.queued.Len() > 0 {
raw := "\x1b[?7l\x1b[?25l" + r.queued.String() // Leave the terminal in the default SGR state between frames
r.setSGR("\x1b[0m")
// Wrap the frame in synchronized update mode (2026) so that the
// terminal applies it atomically. Terminals without support ignore
// the unknown private mode and behave as before.
raw := "\x1b[?2026h\x1b[?7l\x1b[?25l" + r.queued.String()
if r.showCursor { if r.showCursor {
raw += "\x1b[?25h\x1b[?7h" raw += "\x1b[?25h"
} else {
raw += "\x1b[?7h"
} }
raw += "\x1b[?7h\x1b[?2026l"
r.flushRaw(raw) r.flushRaw(raw)
r.queued.Reset() r.queued.Reset()
} }
@@ -128,6 +151,7 @@ type LightRenderer struct {
fullscreen bool fullscreen bool
upOneLine bool upOneLine bool
queued strings.Builder queued strings.Builder
sgr string
y int y int
x int x int
maxHeightFunc func(int) int maxHeightFunc func(int) int
@@ -232,7 +256,7 @@ func (r *LightRenderer) Init() error {
r.csi("G") r.csi("G")
r.csi("K") r.csi("K")
if !r.clearOnExit && !r.fullscreen { if !r.clearOnExit && !r.fullscreen {
r.csi("s") r.stderr("\x1b7") // DECSC: save cursor position
} }
if !r.fullscreen && r.mouse { if !r.fullscreen && r.mouse {
r.yoffset, _ = r.findOffset() r.yoffset, _ = r.findOffset()
@@ -250,15 +274,15 @@ func (r *LightRenderer) makeSpace() {
} }
func (r *LightRenderer) move(y int, x int) { func (r *LightRenderer) move(y int, x int) {
// w.csi("u")
if r.y < y { if r.y < y {
r.csi(fmt.Sprintf("%dB", y-r.y)) r.csi(fmt.Sprintf("%dB", y-r.y))
} else if r.y > y { } else if r.y > y {
r.csi(fmt.Sprintf("%dA", r.y-y)) r.csi(fmt.Sprintf("%dA", r.y-y))
} }
r.stderr("\r") if x == 0 {
if x > 0 { r.stderr("\r")
r.csi(fmt.Sprintf("%dC", x)) } else {
r.csi(fmt.Sprintf("%dG", x+1))
} }
r.y = y r.y = y
r.x = x r.x = x
@@ -1000,6 +1024,9 @@ func (r *LightRenderer) disableModes() {
func (r *LightRenderer) Resume(clear bool, sigcont bool) { func (r *LightRenderer) Resume(clear bool, sigcont bool) {
r.setupTerminal() r.setupTerminal()
// The programs that ran in the meantime may have left the terminal in an
// arbitrary SGR state
r.invalidateSGR()
if clear { if clear {
if r.fullscreen { if r.fullscreen {
r.smcup() r.smcup()
@@ -1021,7 +1048,6 @@ func (r *LightRenderer) Clear() {
if r.fullscreen { if r.fullscreen {
r.csi("H") r.csi("H")
} }
// r.csi("u")
r.origin() r.origin()
r.csi("J") r.csi("J")
r.flush() r.flush()
@@ -1044,7 +1070,6 @@ func (r *LightRenderer) Refresh() {
} }
func (r *LightRenderer) Close() { func (r *LightRenderer) Close() {
// r.csi("u")
if r.clearOnExit { if r.clearOnExit {
if r.fullscreen { if r.fullscreen {
r.rmcup() r.rmcup()
@@ -1056,7 +1081,7 @@ func (r *LightRenderer) Close() {
r.csi("J") r.csi("J")
} }
} else if !r.fullscreen { } else if !r.fullscreen {
r.csi("u") r.stderr("\x1b8") // DECRC: restore cursor position
} }
if !r.showCursor { if !r.showCursor {
r.csi("?25h") r.csi("?25h")
@@ -1273,10 +1298,6 @@ func (w *LightWindow) drawBorder(onlyHorizontal bool) {
} }
} }
func (w *LightWindow) csi(code string) string {
return w.renderer.csi(code)
}
func (w *LightWindow) stderrInternal(str string, allowNLCR bool, resetCode string) { func (w *LightWindow) stderrInternal(str string, allowNLCR bool, resetCode string) {
w.renderer.stderrInternal(str, allowNLCR, resetCode) w.renderer.stderrInternal(str, allowNLCR, resetCode)
} }
@@ -1415,13 +1436,18 @@ func ulColorCode(c Color) string {
return "" return ""
} }
// csiColor builds the SGR sequence for the given colors and attributes
// without emitting it. The sequence starts with a reset parameter, so it
// fully determines the SGR state on its own.
func (w *LightWindow) csiColor(fg Color, bg Color, ul Color, attr Attr) (bool, string) { func (w *LightWindow) csiColor(fg Color, bg Color, ul Color, attr Attr) (bool, string) {
codes := append(attrCodes(attr), colorCodes(fg, bg)...) codes := append(attrCodes(attr), colorCodes(fg, bg)...)
if ulCode := ulColorCode(ul); ulCode != "" { if ulCode := ulColorCode(ul); ulCode != "" {
codes = append(codes, ulCode) codes = append(codes, ulCode)
} }
code := w.csi(";" + strings.Join(codes, ";") + "m") if len(codes) == 0 {
return len(codes) > 0, code return false, "\x1b[0m"
}
return true, "\x1b[;" + strings.Join(codes, ";") + "m"
} }
func (w *LightWindow) Print(text string) { func (w *LightWindow) Print(text string) {
@@ -1434,15 +1460,13 @@ func cleanse(str string) string {
func (w *LightWindow) CPrint(pair ColorPair, text string) { func (w *LightWindow) CPrint(pair ColorPair, text string) {
_, code := w.csiColor(pair.Fg(), pair.Bg(), pair.Ul(), pair.Attr()) _, code := w.csiColor(pair.Fg(), pair.Bg(), pair.Ul(), pair.Attr())
w.renderer.setSGR(code)
w.stderrInternal(cleanse(text), false, code) w.stderrInternal(cleanse(text), false, code)
w.csi("0m")
} }
func (w *LightWindow) cprint2(fg Color, bg Color, attr Attr, text string) { func (w *LightWindow) cprint2(fg Color, bg Color, attr Attr, text string) {
hasColors, code := w.csiColor(fg, bg, colDefault, attr) _, code := w.csiColor(fg, bg, colDefault, attr)
if hasColors { w.renderer.setSGR(code)
defer w.csi("0m")
}
w.stderrInternal(cleanse(text), false, code) w.stderrInternal(cleanse(text), false, code)
} }
@@ -1463,7 +1487,7 @@ func (w *LightWindow) fill(str string, resetCode string) FillReturn {
} }
w.MoveAndClear(w.posy, w.posx) w.MoveAndClear(w.posy, w.posx)
w.Move(w.posy+1, 0) w.Move(w.posy+1, 0)
w.renderer.stderr(resetCode) w.renderer.setSGR(resetCode)
if len(lines) > 1 { if len(lines) > 1 {
sign := w.wrapSign sign := w.wrapSign
width := w.wrapSignWidth width := w.wrapSignWidth
@@ -1473,7 +1497,8 @@ func (w *LightWindow) fill(str string, resetCode string) FillReturn {
width = truncatedWidth width = truncatedWidth
} }
w.stderrInternal(DIM+sign, false, resetCode) w.stderrInternal(DIM+sign, false, resetCode)
w.renderer.stderr(resetCode) w.renderer.invalidateSGR()
w.renderer.setSGR(resetCode)
w.Move(w.posy, width) w.Move(w.posy, width)
} }
} }
@@ -1484,20 +1509,19 @@ func (w *LightWindow) fill(str string, resetCode string) FillReturn {
return FillSuspend return FillSuspend
} }
w.Move(w.posy+1, 0) w.Move(w.posy+1, 0)
w.renderer.stderr(resetCode) w.renderer.setSGR(resetCode)
return FillNextLine return FillNextLine
} }
return FillContinue return FillContinue
} }
func (w *LightWindow) setBg() string { func (w *LightWindow) setBg() string {
if w.bg != colDefault { // The plain reset code for the default background is still required to
_, code := w.csiColor(colDefault, w.bg, colDefault, AttrRegular) // clear the dim attribute after ␍ in the preview window
return code
}
// Should clear dim attribute after ␍ in the preview window
// e.g. printf "foo\rbar" | fzf --ansi --preview 'printf "foo\rbar"' // e.g. printf "foo\rbar" | fzf --ansi --preview 'printf "foo\rbar"'
return "\x1b[m" _, code := w.csiColor(colDefault, w.bg, colDefault, AttrRegular)
w.renderer.setSGR(code)
return code
} }
func (w *LightWindow) LinkBegin(uri string, params string) { func (w *LightWindow) LinkBegin(uri string, params string) {
@@ -1523,7 +1547,7 @@ func (w *LightWindow) CFill(fg Color, bg Color, ul Color, attr Attr, text string
bg = w.bg bg = w.bg
} }
if hasColors, resetCode := w.csiColor(fg, bg, ul, attr); hasColors { if hasColors, resetCode := w.csiColor(fg, bg, ul, attr); hasColors {
defer w.csi("0m") w.renderer.setSGR(resetCode)
return w.fill(text, resetCode) return w.fill(text, resetCode)
} }
return w.fill(text, w.setBg()) return w.fill(text, w.setBg())
+58
View File
@@ -419,3 +419,61 @@ func TestLightRendererScrollWheel(t *testing.T) {
assertScroll("\x1b[<94;1;1M", 0, "ctrl-alt-shift") // ctrl+alt+shift + left (ignored) assertScroll("\x1b[<94;1;1M", 0, "ctrl-alt-shift") // ctrl+alt+shift + left (ignored)
assertScroll("\x1b[<95;1;1M", 0, "ctrl-alt-shift") // ctrl+alt+shift + right (ignored) assertScroll("\x1b[<95;1;1M", 0, "ctrl-alt-shift") // ctrl+alt+shift + right (ignored)
} }
// Assert the exact byte stream emitted for a scripted rendering sequence,
// locking down the SGR deduplication behavior of the light renderer.
func TestLightRendererSGRDeduplication(t *testing.T) {
renderer, _ := NewLightRenderer(
"", nil, &ColorTheme{}, true, false, 8, false, true,
func(h int) int { return h })
r := renderer.(*LightRenderer)
r.width = 80
r.height = 24
out, err := os.CreateTemp(t.TempDir(), "fzf-ttyout")
if err != nil {
t.Fatal(err)
}
r.ttyout = out
w := r.NewWindow(0, 0, 40, 4, WindowList, MakeBorderStyle(BorderNone, true), false).(*LightWindow)
w.fg = colDefault
w.bg = colDefault
r.queued.Reset()
red := NewColorPair(196, colDefault, AttrRegular)
blue := NewColorPair(21, colDefault, AttrRegular)
w.Move(0, 0)
w.CPrint(red, "ab")
w.CPrint(red, "cd") // same pair; no SGR expected
w.CPrint(blue, "ef")
w.Print("gh") // default colors; one reset expected
w.Print("ij") // still default; no SGR expected
w.Move(1, 2)
w.CPrint(red, "kl")
r.invalidateSGR()
w.CPrint(red, "mn") // re-emitted after invalidation
r.flush() // adds a trailing reset to leave the default state
expected := "\x1b[?2026h\x1b[?7l\x1b[?25l" +
"\r" +
"\x1b[;38;5;196mab" +
"cd" +
"\x1b[;38;5;21mef" +
"\x1b[0mgh" +
"ij" +
"\x1b[1B\x1b[3G" +
"\x1b[;38;5;196mkl" +
"\x1b[;38;5;196mmn" +
"\x1b[0m" +
"\x1b[?25h\x1b[?7h\x1b[?2026l"
bytes, err := os.ReadFile(out.Name())
if err != nil {
t.Fatal(err)
}
if string(bytes) != expected {
t.Errorf("expected: %q\n got: %q", expected, string(bytes))
}
}
+290 -21
View File
@@ -248,7 +248,7 @@ class TestLayout < TestInteractive
tmux.send_keys %(seq 5 | #{FZF} --layout=reverse --preview 'echo PREVIEW' --preview-window=next:3 --prompt='line2$ > '), :Enter tmux.send_keys %(seq 5 | #{FZF} --layout=reverse --preview 'echo PREVIEW' --preview-window=next:3 --prompt='line2$ > '), :Enter
expected = <<~OUTPUT expected = <<~OUTPUT
line2$ > line2$ >
5/5 5/5
PREVIEW PREVIEW
@@ -268,7 +268,7 @@ class TestLayout < TestInteractive
5/5 5/5
> >
OUTPUT OUTPUT
tmux.until { assert_block(expected, it) } tmux.until { assert_block(expected, it) }
@@ -597,7 +597,7 @@ class TestLayout < TestInteractive
2 2
1 1
header header
19/97 19/97
> 1 > 1
@@ -628,7 +628,7 @@ class TestLayout < TestInteractive
hello hello
100/100 100/100
> >
BLOCK BLOCK
tmux.until { assert_block(block2, it) } tmux.until { assert_block(block2, it) }
@@ -648,7 +648,7 @@ class TestLayout < TestInteractive
2 2
1 1
98/98 98/98
> >
BLOCK BLOCK
tmux.until { assert_block(block1, it) } tmux.until { assert_block(block1, it) }
@@ -663,7 +663,7 @@ class TestLayout < TestInteractive
1 1
hello hello
98/98 98/98
> >
BLOCK BLOCK
tmux.until { assert_block(block2, it) } tmux.until { assert_block(block2, it) }
@@ -679,7 +679,7 @@ class TestLayout < TestInteractive
4 4
> 3 > 3
98/98 98/98
> >
2 2
@@ -718,7 +718,7 @@ class TestLayout < TestInteractive
2 2
1 1
98/98 98/98
> >
BLOCK BLOCK
tmux.until { assert_block(block1, it) } tmux.until { assert_block(block1, it) }
@@ -734,7 +734,7 @@ class TestLayout < TestInteractive
hello hello
98/98 98/98
> >
BLOCK BLOCK
tmux.until { assert_block(block2, it) } tmux.until { assert_block(block2, it) }
@@ -750,7 +750,7 @@ class TestLayout < TestInteractive
4 4
> 3 > 3
98/98 98/98
> >
2 2
@@ -767,7 +767,7 @@ class TestLayout < TestInteractive
2 2
1 1
98/98 98/98
> >
hello hello
@@ -810,7 +810,7 @@ class TestLayout < TestInteractive
2 2
1 1
header header
19/97 19/97
> 1 > 1
@@ -825,7 +825,7 @@ class TestLayout < TestInteractive
11 11
> 10 > 10
list list
19/97 19/97
> 1 > 1
3 3
@@ -1039,9 +1039,9 @@ class TestLayout < TestInteractive
103 101 103 101 102 102 2/2 103 103 101 103 101 102 102 2/2 103
5/5 102 2/2 102 103 101 103 101 > 2/2 101 101 > 5/5 102 2/2 102 103 101 103 101 > 2/2 101 101 >
> 103 > 103 102 102 > 102 5/5 102 > 103 > 103 102 102 > 102 5/5 102
5/5 103 2/2 103 101 103 > 103 5/5 103 2/2 103 101 103 > 103
> > 102 101 > > 102 101
2/2 103 101 102 2/2 103 101 102
> 102 > 103 > 102 > 103
103 103
@@ -1099,9 +1099,9 @@ class TestLayout < TestInteractive
> 102 > 102 > 101 > 101 101 101 101 > 1 > 102 > 102 > 101 > 101 101 101 101 > 1
3/3 101 1/1 101 102 102 102 102 102 3/3 101 1/1 101 102 102 102 102 102
> 102 > 102 HEAD 101 HEAD HEAD HEAD 101 1/1 101 > 102 > 102 HEAD 101 HEAD HEAD HEAD 101 1/1 101
3/3 102 1/1 1/1 1/1 102 > 102 3/3 > 3/3 102 1/1 1/1 1/1 102 > 102 3/3 >
> HEAD > > > HEAD HEAD > > HEAD > > > HEAD HEAD >
1/1 1/1
> 101 101 > 101 101
102 102 102 102
HEAD HEAD HEAD HEAD
@@ -1159,9 +1159,9 @@ class TestLayout < TestInteractive
> 102 > 102 > 101 > 101 > 3 > 3 > 3 101 3 > 102 > 102 > 101 > 101 > 3 > 3 > 3 101 3
3/3 101 1/1 101 102 102 102 3/3 101 1/1 101 102 102 102
> 102 > 102 HEAD 101 HEAD 101 1/1 101 > 102 > 102 HEAD 101 HEAD 101 1/1 101
3/3 102 1/1 102 > 102 3/3 > 3/3 102 1/1 102 > 102 3/3 >
> HEAD > HEAD HEAD > > HEAD > HEAD HEAD >
1/1 1/1
> 101 101 > 101 101
102 102 102 102
HEAD HEAD HEAD HEAD
@@ -1191,7 +1191,7 @@ class TestLayout < TestInteractive
label label
header header
10/10 10/10
> >
BLOCK BLOCK
@@ -1228,13 +1228,282 @@ class TestLayout < TestInteractive
end end
end end
def test_separator_with_input_border
# Border line below input does not face the list; separator is shown
tmux.send_keys %(seq 100 | #{FZF} --input-border bottom), :Enter
block = <<~BLOCK
> 1
100/100
>
BLOCK
tmux.until { assert_block(block, it) }
teardown
setup
# List border faces the input instead; separator is hidden
tmux.send_keys %(seq 100 | #{FZF} --input-border bottom --list-border rounded), :Enter
block = <<~BLOCK
> 1
100/100
>
BLOCK
tmux.until { assert_block(block, it) }
teardown
setup
# Border line above input faces the list; separator is hidden
tmux.send_keys %(seq 100 | #{FZF} --input-border top), :Enter
block = <<~BLOCK
> 1
100/100
>
BLOCK
tmux.until { assert_block(block, it) }
end
def test_separator_with_input_border_reverse
# Border line above input does not face the list; separator is shown
tmux.send_keys %(seq 100 | #{FZF} --layout reverse --input-border top), :Enter
block = <<~BLOCK
>
100/100
> 1
BLOCK
tmux.until { assert_block(block, it) }
teardown
setup
# Border line below input faces the list; separator is hidden
tmux.send_keys %(seq 100 | #{FZF} --layout reverse --input-border bottom), :Enter
block = <<~BLOCK
>
100/100
> 1
BLOCK
tmux.until { assert_block(block, it) }
end
def test_separator_with_preview_next
# Preview window at 'next' position sits next to the input section, so the
# header border does not hide the separator
tmux.send_keys %(seq 100 | #{FZF} --header foo --header-border rounded --no-list-border --preview 'echo hi' --preview-window next,1,border-none), :Enter
block = <<~BLOCK
hi
100/100
>
BLOCK
tmux.until { assert_block(block, it) }
teardown
setup
# Same when 'next' position comes from a threshold alternative
tmux.send_keys %(seq 100 | #{FZF} --header foo --header-border rounded --no-list-border --preview 'echo hi' --preview-window 'up,40%,<9999(next,1,border-none)'), :Enter
tmux.until { assert_block(block, it) }
teardown
setup
# A border of the preview window at 'next' position facing the input
# section hides the separator
tmux.send_keys %(seq 100 | #{FZF} --preview : --preview-window next,3), :Enter
block = <<~BLOCK
100/100
>
BLOCK
tmux.until { assert_block(block, it) }
teardown
setup
# Same when every spec in the threshold chain is a bordered preview
# window at 'next' position
tmux.send_keys %(seq 100 | #{FZF} --preview : --preview-window 'next,3,<15(next,3)'), :Enter
tmux.until { assert_block(block, it) }
end
def test_separator_with_preview_next_toggle
tmux.send_keys %(seq 100 | #{FZF} --preview 'echo hi' --preview-window next,3,hidden --bind space:toggle-preview), :Enter
hidden = <<~BLOCK
> 1
100/100
>
BLOCK
tmux.until { assert_block(hidden, it) }
tmux.send_keys :Space
shown = <<~BLOCK
hi
100/100
>
BLOCK
tmux.until { assert_block(shown, it) }
tmux.send_keys :Space
tmux.until { assert_block(hidden, it) }
end
def test_separator_with_preview_next_alternative_only
# 'next' appears only in a threshold alternative and there is no
# previewer, so the input section stays embedded in the list window and
# needs the separator
tmux.send_keys %(seq 100 | #{FZF} --preview-window '<9999(next)' --list-border rounded), :Enter
block = <<~BLOCK
> 1
100/100
>
BLOCK
tmux.until { assert_block(block, it) }
end
def test_separator_with_preview_action_at_next
# preview(...) action can display an ad-hoc preview window at 'next'
# position at any time; the list border must not hide the separator
tmux.send_keys %(seq 100 | #{FZF} --list-border rounded --preview-window 'next,border-left,5' --bind 'space:preview(echo hi)'), :Enter
block = <<~BLOCK
100/100
>
BLOCK
tmux.until { assert_block(block, it) }
tmux.send_keys :Space
block = <<~BLOCK
hi
100/100
>
BLOCK
tmux.until { assert_block(block, it) }
end
def test_separator_change_preview_window_keeps_forced_preview
# Relayout triggered by the separator visibility change must not drop a
# preview window opened via the preview(...) action
tmux.send_keys %(seq 100 | #{FZF} --info hidden --input-border left --list-border --preview-window 'up,50%,<5(next)' --bind 'space:preview(echo AD-HOC)' --bind 'x:change-preview-window(up,50%,<5(up))'), :Enter
tmux.until { |lines| assert_includes lines.join, '│ ─' }
tmux.send_keys :Space
tmux.until do |lines|
assert_includes lines.join, 'AD-HOC'
assert_includes lines.join, '│ ─'
end
# Only the inactive threshold alternative changes; separator is hidden
# by the list border, and the forced preview window must survive
tmux.send_keys :x
tmux.until do |lines|
assert_includes lines.join, 'AD-HOC'
refute_includes lines.join, '│ ─'
end
end
def test_separator_with_change_preview_window_chain
# change-preview-window that only alters a non-active spec must still
# trigger a relayout when it changes the separator visibility
tmux.send_keys %(seq 100 | #{FZF} --info hidden --layout reverse --preview 'echo hi' --preview-window 'next,3,border-none,<100(next,3,border-top)' --bind 'space:change-preview-window(next,3,border-top,<100(next,3,border-top))'), :Enter
block = <<~BLOCK
>
hi
BLOCK
tmux.until { assert_block(block, it) }
tmux.send_keys :Space
block = <<~BLOCK
>
hi
BLOCK
tmux.until { assert_block(block, it) }
teardown
setup
# Also when only the drawn bar changes; the info line row count is
# constant with the default info style
tmux.send_keys %(seq 100 | #{FZF} --layout reverse --preview 'echo hi' --preview-window 'next,3,border-none,<100(next,3,border-top)' --bind 'space:change-preview-window(next,3,border-top,<100(next,3,border-top))'), :Enter
block = <<~BLOCK
>
100/100
BLOCK
tmux.until { assert_block(block, it) }
tmux.send_keys :Space
block = <<~BLOCK
>
100/100
BLOCK
tmux.until { assert_block(block, it) }
teardown
setup
# Also when compare finds a content-layout difference (scroll offset)
tmux.send_keys %(seq 100 | #{FZF} --info hidden --preview 'echo hi' --preview-window 'next,3,<2(up,3)' --bind 'space:change-preview-window(next,3,+1,<2(next,3))'), :Enter
block = <<~BLOCK
>
BLOCK
tmux.until { assert_block(block, it) }
tmux.send_keys :Space
block = <<~BLOCK
>
BLOCK
tmux.until { assert_block(block, it) }
end
def test_separator_with_inline_header_border
# Inline header is drawn inside the list border; bare header lines sit
# next to the input section, so the separator is shown
tmux.send_keys %(seq 100 | #{FZF} --list-border rounded --header foo --header-lines 2 --header-border inline --header-lines-border none), :Enter
block = <<~BLOCK
2
1
98/98
>
BLOCK
tmux.until { assert_block(block, it) }
teardown
setup
# Header lines are embedded in the list border along with the inline
# header; the list border faces the input section and hides the separator
tmux.send_keys %(seq 100 | #{FZF} --list-border rounded --header foo --header-lines 2 --header-border inline), :Enter
block = <<~BLOCK
2
1
foo
98/98
>
BLOCK
tmux.until { assert_block(block, it) }
end
def test_header_border_no_pointer_and_marker def test_header_border_no_pointer_and_marker
tmux.send_keys %(seq 10 | #{FZF} --header-lines 1 --header-border sharp --no-list-border --pointer '' --marker ''), :Enter tmux.send_keys %(seq 10 | #{FZF} --header-lines 1 --header-border sharp --no-list-border --pointer '' --marker ''), :Enter
block = <<~BLOCK block = <<~BLOCK
1 1
9/9 9/9
> >
BLOCK BLOCK
tmux.until { assert_block(block, it) } tmux.until { assert_block(block, it) }