Fix --preview-window follow not working correctly with wrapping

Fix #3243
Fix #4258
This commit is contained in:
Junegunn Choi
2026-02-18 21:36:23 +09:00
parent 69e9abdab4
commit c338df02c4
6 changed files with 98 additions and 8 deletions

View File

@@ -36,6 +36,7 @@ CHANGELOG
- Added fish completion support (#4605) (@lalvarezt)
- zsh: Handle multi-line history selection (#4595) (@LangLangBart)
- Bug fixes
- Fixed `--preview-window follow` not working correctly with wrapping (#3243, #4258)
- Fixed symlinks to directories being returned as files (#4676) (@skk64)
- Fixed SIGHUP signal handling (#4668) (@LangLangBart)
- Fixed preview process not killed on exit (#4667)

View File

@@ -4093,6 +4093,80 @@ func extractPassThroughs(line string) ([]string, string) {
return passThroughs, transformed
}
// followOffset computes the correct content-line offset for follow mode,
// accounting for line wrapping in the preview window.
func (t *Terminal) followOffset() int {
lines := t.previewer.lines
headerLines := t.activePreviewOpts.headerLines
height := t.pwindow.Height() - headerLines
if height <= 0 || len(lines) <= headerLines {
return headerLines
}
body := lines[headerLines:]
if !t.activePreviewOpts.wrap {
return max(t.previewer.offset, headerLines+len(body)-height)
}
maxWidth := t.pwindow.Width()
visualLines := 0
for i := len(body) - 1; i >= 0; i-- {
h := t.previewLineHeight(body[i], maxWidth)
if visualLines+h > height {
return headerLines + i + 1
}
visualLines += h
}
return headerLines
}
// previewLineHeight estimates the number of visual lines a preview content line
// occupies when wrapping is enabled.
func (t *Terminal) previewLineHeight(line string, maxWidth int) int {
if maxWidth <= 0 {
return 1
}
// For word-wrap mode, count the sub-lines produced by word wrapping.
// Each sub-line may still char-wrap if it contains a word longer than the width.
if t.activePreviewOpts.wrapWord {
subLines := t.wordWrapAnsiLine(line, maxWidth, t.wrapSignWidth)
total := 0
for i, sub := range subLines {
prefixWidth := 0
cols := maxWidth
if i > 0 {
prefixWidth = t.wrapSignWidth
cols -= t.wrapSignWidth
}
w := t.ansiLineWidth(sub, prefixWidth)
if cols <= 0 {
cols = 1
}
total += max(1, (w+cols-1)/cols)
}
return total
}
// For char-wrap, compute visible width and divide by available width.
w := t.ansiLineWidth(line, 0)
if w <= maxWidth {
return 1
}
remaining := w - maxWidth
contWidth := max(1, maxWidth-t.wrapSignWidth)
return 1 + (remaining+contWidth-1)/contWidth
}
// ansiLineWidth computes the display width of a string, skipping ANSI escape sequences.
// prefixWidth is the visual offset where the content starts (e.g. wrap sign width for
// continuation lines), used for correct tab stop alignment.
func (t *Terminal) ansiLineWidth(line string, prefixWidth int) int {
trimmed, _, _ := extractColor(line, nil, nil)
_, width := t.processTabs([]rune(trimmed), prefixWidth)
return width - prefixWidth
}
func (t *Terminal) wordWrapAnsiLine(line string, maxWidth int, wrapSignWidth int) []string {
if maxWidth <= 0 {
return []string{line}
@@ -5704,7 +5778,7 @@ func (t *Terminal) Loop() error {
t.previewer.lines = result.lines
t.previewer.spinner = result.spinner
if t.hasPreviewWindow() && t.previewer.following.Enabled() {
t.previewer.offset = max(t.previewer.offset, len(t.previewer.lines)-(t.pwindow.Height()-t.activePreviewOpts.headerLines))
t.previewer.offset = t.followOffset()
} else if result.offset >= 0 {
t.previewer.offset = util.Constrain(result.offset, t.activePreviewOpts.headerLines, len(t.previewer.lines)-1)
}

View File

@@ -1423,8 +1423,10 @@ func (w *LightWindow) fill(str string, resetCode string) FillReturn {
for i, line := range allLines {
lines := WrapLine(line, w.posx, w.width, w.tabstop, w.wrapSignWidth)
for j, wl := range lines {
w.stderrInternal(wl.Text, false, resetCode)
w.posx += wl.DisplayWidth
if w.posx < w.width {
w.stderrInternal(wl.Text, false, resetCode)
w.posx += wl.DisplayWidth
}
// Wrap line
if j < len(lines)-1 || i < len(allLines)-1 {

View File

@@ -971,7 +971,9 @@ func (w *TcellWindow) fillString(text string, pair ColorPair) FillReturn {
w.renderWrapSign(style)
}
}
w.renderGraphemes(wl.Text, style)
if w.lastX < w.width {
w.renderGraphemes(wl.Text, style)
}
}
}
if w.lastX >= w.width {

View File

@@ -1377,7 +1377,8 @@ func WrapLine(input string, prefixLength int, initialMax int, tabstop int, wrapS
width := 0
line := ""
gr := uniseg.NewGraphemes(input)
max := initialMax
maxWidth := initialMax
contMax := max(1, initialMax-wrapSignWidth)
for gr.Next() {
rs := gr.Runes()
str := string(rs)
@@ -1392,14 +1393,14 @@ func WrapLine(input string, prefixLength int, initialMax int, tabstop int, wrapS
}
width += w
if prefixLength+width <= max {
if prefixLength+width <= maxWidth {
line += str
} else {
lines = append(lines, WrappedLine{string(line), width - w})
line = str
prefixLength = 0
width = w
max = initialMax - wrapSignWidth
maxWidth = contMax
}
}
lines = append(lines, WrappedLine{string(line), width})

View File

@@ -383,6 +383,16 @@ class TestPreview < TestInteractive
end
end
def test_preview_follow_wrap
tmux.send_keys "seq 1 | #{FZF} --preview 'seq 1000' --preview-window right,2,follow,wrap", :Enter
tmux.until { |lines| assert_equal 1, lines.match_count }
tmux.until do |lines|
assert_includes lines[-4], '│ 10 │'
assert_includes lines[-3], '│ ↳ │'
assert_includes lines[-2], '│ ↳ │'
end
end
def test_close
tmux.send_keys "seq 100 | #{FZF} --preview 'echo foo' --bind ctrl-c:close", :Enter
tmux.until { |lines| assert_equal 100, lines.match_count }
@@ -541,7 +551,7 @@ class TestPreview < TestInteractive
tmux.send_keys "seq 10 | #{FZF} --preview-border rounded --preview-window '~5,2,+0,<100000(~0,+100,wrap,noinfo)' --preview 'seq 1000'", :Enter
tmux.until { |lines| assert_equal 10, lines.match_count }
tmux.until do |lines|
assert_equal ['╭────╮', '│ 10 │', '│ ↳ 0│', '│ 10 │', '│ ↳ 1│'], lines.take(5).map(&:strip)
assert_equal ['╭────╮', '│ 10 │', '│ ↳ │', '│ 10 │', '│ ↳ │'], lines.take(5).map(&:strip)
end
end